Skip to content

feat(control-api): serve the GFS watch over GET/PUT /external/gfs/watch - #1500

Open
zach88 wants to merge 3 commits into
feat/gfs-event-subscriptions-api-registryfrom
feat/gfs-event-subscriptions-api-http
Open

zach88 wants to merge 3 commits into
feat/gfs-event-subscriptions-api-registryfrom
feat/gfs-event-subscriptions-api-http

Conversation

@zach88

@zach88 zach88 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

Mounts the HTTP boundary of the GFS watch registry (#1499) in control-api.

  • GET /external/gfs/watch?k=<key>&k=... opens an NDJSON stream. Steps: token, key parsing, external session authentication, listener readiness, the per-user open limiter, then registry.open.
  • PUT /external/gfs/watch changes a live connection's subscription set. Body validation (keys capped at 64 before any per-element work), authentication, its own limiter bucket before the authority lookup, and a gfs_watch_cmd NOTIFY built by serializeWatchCmd. Replies 202 {seq}; the decision arrives on the stream.
  • HEAD and every other method get 405 with Allow: GET, PUT, charged once on the shared per-IP ingress limiter, so OPTIONS does not bypass it.
  • GFS_WATCH_HEARTBEAT_MS (default 20 s, range 5 to 25 s).
  • Registry start at boot; stopGfsWatchRegistry inside stopOnce before server.close; a watch-database-pool shutdown step before the core pool.

Why

This is the only entry point to the watch. Its contract is what ERA (#1501) relays and what the desktop client (#1503) keys its retry decisions on, so every non-2xx carries a machine-readable reason.

How

  • Only V1 external sessions are accepted. A V2 session gets 409 session_contract_unsupported; an outdated client gets 426. V2 sessions cannot be issued today; supporting them needs its own design for the session binding and its revocation hints (GFS watch: support V2 sessions before enabling V2 issuance #1504).
  • Every handler is total: a single response writer, each await wrapped and classified, x-watch-error on every non-2xx the handler originates, and 503 watch_reopen instead of a 500.
  • The router mounts ahead of the GFS router and reuses its ingress limiter instance and request-id middleware, so a watch request is charged once. The ingress 429 on the watch route is tagged by req.route, so path spelling (trailing slash, case) cannot drop the code.
  • Responses this handler does not originate (the global JSON parser's 400, the internal service gate's 401) keep their usual shape without x-watch-error. The client treats them by status, with the same effect.

Testing

  • gfsWatch.routes.realPostgres (real registry, real Postgres):
    • a stream opened by GET whose set changes in place through a PUT routed by NOTIFY;
    • 401 / 409 / 426 / 503 session outcomes, 400 for non-canonical keys and sets over 64, 64 keys accepted on both GET and PUT;
    • defects in the session, limiter, authority or deadline setup answer 503 watch_reopen, never a 500;
    • authority, listener-down, retained-user and admission outcomes;
    • open and PUT limiters in separate buckets, PUT charged before the authority lookup;
    • a PUT for a connection the user does not own changes nothing;
    • ingress budget shared with the GFS router and charged once, OPTIONS and HEAD answered 405 through it, the 429 tagged on every path spelling;
    • shutdown closes every stream with server_shutdown before ending the watch pool.
  • config.gfsWatchHeartbeat: default, range and invalid values.
  • server.gfsWatchShutdown and shutdown: registry stop and pool drain ordering.
  • At this head: tsc --noEmit clean; npm test 636 files / 8956 tests passed (real-Postgres suites skip in this lane); real-Postgres run of the watch suites (gfsWatch*, gfs.externalGfsRead*): 18 files / 238 tests passed. db.realPostgresMigration fails 41 of 75 cases on the test host because its production access verifier shells out to psql, which is not installed there; the same 41 fail on dev.

Risks / follow-ups

Stack

Live GFS subscriptions ship as a stack. The server half and the desktop half must reach dev together: the server PRs retire the route the current desktop uses, and the desktop PR needs the new watch route.

# PR Base Scope
1 #1496 dev gfs-controller: drive name validation, principal_denied reason
2 #1497 dev host-context-controller: drive name validation (merge after 1)
3 #1498 dev control-api: shared building blocks, per-resource change trigger
4 #1499 3 control-api: watch registry, listener, process runtime
5 #1500 4 control-api: GET/PUT /external/gfs/watch
6 #1501 5 external-rest-api: GET/PUT /api/v1/me/gfs/watch
7 #1502 6 control-api + ERA: authorization and session push, retire the per-user entity-change stream
8 #1503 7 desktop-app: watch client, live previews and folders

1 and 2 are independent of the chain and safe to merge on their own (1 before 2, since the controller check in 2 mirrors the one in 1).

3 to 8 are one unit and should not be deployed partially:

  • 3 and 4 add groundwork; the only new runtime behavior is the gfs_resource_changed NOTIFY trigger, which nothing listens to yet.
  • 5 and 6 expose the route, but until 7 lands an authorization or session change does not cut the changed events of a stream that is idle (no op in flight). Do not ship 5/6 without 7.
  • 7 removes the per-user entity-change stream (ERA /api/v1/entity-changes/stream and control-api /external/entity-changes/stream), which the current desktop still uses. Do not ship 7 without 8.

zach88 and others added 3 commits November 12, 2026 11:33
Mount the HTTP boundary of the per-process GFS watch registry:

- GET opens the NDJSON stream: token, key parsing, external session
  authentication (V1 only: 409 for V2, 426 for upgrade_required), listener
  readiness and the open limiter, then hands off to registry.open.
- PUT mutates a live connection's subscription set: body validation (keys
  capped at 64 before any per-element work), authentication, its own
  limiter bucket before the authority lookup, and a gfs_watch_cmd NOTIFY
  built by serializeWatchCmd; replies 202 {seq}.
- HEAD and every other method get 405 with Allow: GET, PUT, charged once on
  the shared per-IP ingress limiter so OPTIONS no longer bypasses it.
- Every handler is total: one response writer, step/classify around each
  await, x-watch-error on every non-2xx, 503 watch_reopen instead of 500.
- The router mounts ahead of the GFS router and reuses its ingress limiter
  instance and request-id middleware; the ingress 429 on the watch route is
  tagged by req.route so path spelling cannot drop the code.
- GFS_WATCH_HEARTBEAT_MS (default 20 s, range 5-25 s), registry start at
  boot, stopRegistry inside stopOnce before server.close, and a
  watch-database-pool shutdown step before the core pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-event-subscriptions-api-http

Brings origin/dev (22dd421) through the synced parent branch.
No textual conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant