Repository navigation
Conversation
Groundwork for the live GFS watch stream, with no behaviour change for existing callers: - isTransientInfraError: single classifier for pg / pg-pool / network / abort failures, including the session backend-unavailable wrapper. - setStreamHeaders shared by the entity-change and notification streams. - createRateLimitCheck: pure rate-limit decision plus a separate header writer; createRateLimitEnforcer is recomposed from both. - externalWatchRateLimit: entity-change limiter config moved out of its route, plus per-user open/PUT limiters for the watch. - Module-level external GFS ingress limiter and edge handler, exported together with attachExternalGfsRequestId. - resolveExternalGfsAuthorityForSession, now used by attachExternalGfsAuthority (same generation for V1, none for V2). - mintExternalGfsReadToken shared with proxyReadToGfsc, and readGfsc, a typed gfsc read that never rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the gfs_resource_changed_notify trigger on gfs_resources (migration
0139). Every row write emits pg_notify('gfs_resource_changed',
'rid,old,new,h,a,op'): uuids are cast to text before coalescing so the
trigger can never abort a GFS write, search_path is pinned and EXECUTE is
revoked from PUBLIC like the existing outbox trigger functions.
Add the watch key helpers (canonical gfs://<drive>/<rid32> keys, the
normalisers for values from other layers) and the NOTIFY payload schemas
for the resource, ping and command channels. Nothing listens yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ptions-api-base Resolve control-api package.json/package-lock.json version conflict by keeping the higher version (0.1.944). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 14, 2026
| const app = express() | ||
| app.get( | ||
| '/authority', | ||
| (req, res, next) => void mod.authority.attachExternalGfsAuthority(req, res, next) |
|
|
||
| let typeError: unknown | ||
| try { | ||
| ;(undefined as unknown as { x: () => void }).x() |
| import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| import express, { type NextFunction, type Request, type Response } from 'express' | ||
| import { createPublicKey, randomBytes, randomUUID } from 'node:crypto' | ||
| import { once } from 'node:events' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
First control-api slice of the live GFS watch. Two commits:
refactor):isTransientInfraError: one classifier for pg, pg-pool, network and abort failures, including the session backend-unavailable wrapper.setStreamHeaders, now shared by the entity-change and notification streams.createRateLimitCheck: the rate-limit decision as a pure function plus a separate header writer.createRateLimitEnforceris recomposed from both.externalWatchRateLimitmodule: the entity-change limiter config moves out of its route, next to the new per-user open/PUT limiters for the watch.attachExternalGfsRequestId.resolveExternalGfsAuthorityForSession, now used byattachExternalGfsAuthority.mintExternalGfsReadToken, shared withproxyReadToGfsc, andreadGfsc, a typed gfs-controller read that never rejects.feat):0139adds thegfs_resource_changed_notifytrigger ongfs_resources. Every row write emitspg_notify('gfs_resource_changed', 'rid,old,new,h,a,op').keys.ts: canonical watch keysgfs://<drive>/<rid32>and the normalizers for values that come from other layers.notifySchemas.ts/resourceChangedSchema.ts: total parsers for the resource, ping and command channel payloads.Nothing listens to the new channel yet; the registry comes in #1499.
Why
Today the desktop learns about GFS changes from a per-user stream that sends a generic "something may have changed" notice every 4 seconds by default, with or without changes. Each one makes the app re-resolve open previews and refetch active GFS queries, whether or not anything changed, and each re-resolve remounts the preview body (scroll and video position are lost, "Loading preview" flashes).
This stack replaces it with a watch stream: the client subscribes to the resources it is showing and only hears about the ones that changed. This PR adds the pieces the watch is built on, and extracts the ones it shares with existing routes so the logic is not duplicated.
How
search_pathis pinned andEXECUTEis revoked fromPUBLIC, like the existing outbox trigger functions.readGfscnever rejects: it returns a typed result (2xxwith the raw kind,401,403withprincipalDenied,404,410, ortransient), so every await in the watch classifies its outcome in one place. 409, 429, 5xx, unparseable 2xx bodies, network failures, aborts and its deadline are alltransient.Testing
Real-Postgres suites use the repo's integration harness (
CONTROL_API_REAL_PG_ADMIN_URL).gfsWatch.resourceChangedTrigger.realPostgres: one parseable payload per insert, rename, move (with both parents), content update, soft delete, restore and hard delete; boundary values never abort a write and notify only on commit; pinnedsearch_path, noPUBLICexecute, and the trigger still fires for the gfs-controller writer role.gfsWatch.keys.property: fast-check properties for key canonicalization (round trip and rejection of non-canonical input).gfs.externalGfsRead(unit and real Postgres): every gfs-controller response class maps to one tagged outcome; the shared mint matches the old proxy path for the linked-admin case.gfsWatch.transientInfra(unit and real Postgres): classification against real pg / pool / socket failures.gfsWatch.rateLimitCheck.realPostgres: the pure check and the shared header writer produce the allowed, denied and unavailable decisions and headers.0139.tsc --noEmitclean;npm test630 files / 8888 tests passed (real-Postgres suites skip in this lane); real-Postgres run of the watch suites (gfsWatch*,gfs.externalGfsRead*): 7 files / 80 tests passed.db.realPostgresMigrationfails 41 of 75 cases on the test host because its production access verifier shells out topsql, which is not installed there; the same 41 fail ondev.Risks / follow-ups
gfs_resourceswrite now emits one NOTIFY. With no listener this is a cheap no-op on the Postgres side; the listener arrives in feat(control-api): add the GFS watch registry, listener and process runtime #1499.Stack
Live GFS subscriptions ship as a stack. The server half and the desktop half must reach
devtogether: the server PRs retire the route the current desktop uses, and the desktop PR needs the new watch route.devprincipal_deniedreasondevdevGET/PUT /external/gfs/watchGET/PUT /api/v1/me/gfs/watch1 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:
gfs_resource_changedNOTIFY trigger, which nothing listens to yet.changedevents of a stream that is idle (no op in flight). Do not ship 5/6 without 7./api/v1/entity-changes/streamand control-api/external/entity-changes/stream), which the current desktop still uses. Do not ship 7 without 8.