@agent-native/core 0.116.0 → 0.117.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +21 -0
  3. package/corpus/core/package.json +4 -1
  4. package/corpus/core/src/client/analytics.ts +7 -0
  5. package/corpus/core/src/client/use-db-sync.ts +354 -15
  6. package/corpus/core/src/collab/client.ts +26 -2
  7. package/corpus/core/src/db/ddl-guard.ts +49 -10
  8. package/corpus/core/src/deploy/build.ts +25 -2
  9. package/corpus/core/src/deploy/workspace-deploy.ts +14 -4
  10. package/corpus/core/src/realtime-protocol.ts +92 -0
  11. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  12. package/corpus/core/src/server/poll-events.ts +29 -8
  13. package/corpus/core/src/server/poll.ts +1123 -865
  14. package/corpus/core/src/server/realtime-token.ts +106 -0
  15. package/corpus/core/src/server/sentry-config.ts +40 -0
  16. package/corpus/core/src/server/short-lived-token.ts +156 -0
  17. package/corpus/core/src/server/sse.ts +15 -0
  18. package/corpus/core/src/server/ssr-handler.ts +9 -3
  19. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  20. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  21. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  22. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  23. package/corpus/templates/content/app/i18n-data.ts +90 -0
  24. package/dist/client/analytics.d.ts +10 -0
  25. package/dist/client/analytics.d.ts.map +1 -1
  26. package/dist/client/analytics.js.map +1 -1
  27. package/dist/client/use-db-sync.d.ts +1 -1
  28. package/dist/client/use-db-sync.d.ts.map +1 -1
  29. package/dist/client/use-db-sync.js +315 -15
  30. package/dist/client/use-db-sync.js.map +1 -1
  31. package/dist/collab/client.d.ts.map +1 -1
  32. package/dist/collab/client.js +26 -2
  33. package/dist/collab/client.js.map +1 -1
  34. package/dist/collab/routes.d.ts +1 -1
  35. package/dist/collab/struct-routes.d.ts +1 -1
  36. package/dist/db/ddl-guard.d.ts +7 -2
  37. package/dist/db/ddl-guard.d.ts.map +1 -1
  38. package/dist/db/ddl-guard.js +21 -9
  39. package/dist/db/ddl-guard.js.map +1 -1
  40. package/dist/deploy/build.d.ts.map +1 -1
  41. package/dist/deploy/build.js +25 -2
  42. package/dist/deploy/build.js.map +1 -1
  43. package/dist/deploy/workspace-deploy.js +14 -4
  44. package/dist/deploy/workspace-deploy.js.map +1 -1
  45. package/dist/notifications/routes.d.ts +1 -1
  46. package/dist/observability/routes.d.ts +1 -1
  47. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  48. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  49. package/dist/realtime-protocol.d.ts +40 -0
  50. package/dist/realtime-protocol.d.ts.map +1 -0
  51. package/dist/realtime-protocol.js +69 -0
  52. package/dist/realtime-protocol.js.map +1 -0
  53. package/dist/resources/handlers.d.ts +1 -1
  54. package/dist/secrets/routes.d.ts +3 -3
  55. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  56. package/dist/server/core-routes-plugin.js +3 -0
  57. package/dist/server/core-routes-plugin.js.map +1 -1
  58. package/dist/server/poll-events.d.ts +7 -1
  59. package/dist/server/poll-events.d.ts.map +1 -1
  60. package/dist/server/poll-events.js +24 -7
  61. package/dist/server/poll-events.js.map +1 -1
  62. package/dist/server/poll.d.ts +223 -42
  63. package/dist/server/poll.d.ts.map +1 -1
  64. package/dist/server/poll.js +849 -737
  65. package/dist/server/poll.js.map +1 -1
  66. package/dist/server/realtime-token.d.ts +34 -0
  67. package/dist/server/realtime-token.d.ts.map +1 -0
  68. package/dist/server/realtime-token.js +85 -0
  69. package/dist/server/realtime-token.js.map +1 -0
  70. package/dist/server/sentry-config.d.ts +12 -0
  71. package/dist/server/sentry-config.d.ts.map +1 -1
  72. package/dist/server/sentry-config.js +34 -0
  73. package/dist/server/sentry-config.js.map +1 -1
  74. package/dist/server/short-lived-token.d.ts +46 -0
  75. package/dist/server/short-lived-token.d.ts.map +1 -1
  76. package/dist/server/short-lived-token.js +104 -0
  77. package/dist/server/short-lived-token.js.map +1 -1
  78. package/dist/server/sse.d.ts +1 -1
  79. package/dist/server/sse.d.ts.map +1 -1
  80. package/dist/server/sse.js +4 -0
  81. package/dist/server/sse.js.map +1 -1
  82. package/dist/server/ssr-handler.d.ts.map +1 -1
  83. package/dist/server/ssr-handler.js +5 -3
  84. package/dist/server/ssr-handler.js.map +1 -1
  85. package/package.json +4 -1
  86. package/src/client/analytics.ts +7 -0
  87. package/src/client/use-db-sync.ts +354 -15
  88. package/src/collab/client.ts +26 -2
  89. package/src/db/ddl-guard.ts +49 -10
  90. package/src/deploy/build.ts +25 -2
  91. package/src/deploy/workspace-deploy.ts +14 -4
  92. package/src/realtime-protocol.ts +92 -0
  93. package/src/server/core-routes-plugin.ts +7 -0
  94. package/src/server/poll-events.ts +29 -8
  95. package/src/server/poll.ts +1123 -865
  96. package/src/server/realtime-token.ts +106 -0
  97. package/src/server/sentry-config.ts +40 -0
  98. package/src/server/short-lived-token.ts +156 -0
  99. package/src/server/sse.ts +15 -0
  100. package/src/server/ssr-handler.ts +9 -3
@@ -11,7 +11,15 @@
11
11
  * application_state and settings tables' updated_at timestamps. This ensures
12
12
  * that changes made by external processes (e.g., CLI actions, cron jobs)
13
13
  * are picked up even though they don't call recordChange() in this process.
14
+ *
15
+ * All change-tracking state lives on an {@link AppSyncState} instance rather
16
+ * than in module-level singletons, so a single process can hold one isolated
17
+ * instance per app (the hosted Realtime Gateway serves many apps at once). The
18
+ * module-level exports below delegate to a lazily-created default instance
19
+ * bound to the process-global DB, so self-hosted apps run exactly one code
20
+ * path with no behavioral change.
14
21
  */
22
+ import { createHash } from "node:crypto";
15
23
  import { EventEmitter } from "node:events";
16
24
  import { defineEventHandler, getQuery, setResponseStatus } from "h3";
17
25
  import { ACTION_CHANGE_MARKER_KEY, parseActionChangeMarker, } from "../action-change-marker.js";
@@ -27,66 +35,19 @@ const MAX_BUFFER = 200;
27
35
  const DURABLE_READ_LIMIT = 1000;
28
36
  const DURABLE_RETENTION_MS = 24 * 60 * 60 * 1000;
29
37
  const LEGACY_DB_CHECK_INTERVAL_MS = 1000;
30
- const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30_000;
31
- let _version = 0;
32
- const _buffer = [];
38
+ export const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30_000;
33
39
  export const POLL_CHANGE_EVENT = "poll-change";
34
- const _pollEmitter = new EventEmitter();
35
- _pollEmitter.setMaxListeners(0);
36
- let _syncEventsInitPromise;
37
- let _lastDurablePrune = 0;
38
- /**
39
- * Whether we've seeded _version from the DB. In serverless (Netlify,
40
- * Vercel, etc.) each invocation starts fresh — without seeding, _version
41
- * resets to 0 and polling clients see the version jump backwards, causing
42
- * duplicate events and stuck UI.
43
- */
44
- let _versionSeeded = false;
45
- /** Tracks the latest updated_at we've seen from the DB, per table. */
46
- let _lastDbCheck = 0;
47
- // Coalesces concurrent checkExternalDbChanges runs. The 1s throttle alone does
48
- // not prevent overlap when a single check takes longer than 1s — two overlapping
49
- // runs would each read+advance the shared watermarks and double-emit events.
50
- let _checkPromise = null;
51
- let _lastAppStateTs = 0;
52
- let _lastSettingsTs = 0;
53
- let _lastExtensionsTs = 0;
54
- let _lastExtensionsUpdatedAt;
55
- let _lastExtensionMarkerTs = 0;
56
- let _lastActionMarkerTs = 0;
40
+ /** TTL for an allowed (true) cache entry. */
41
+ const ACCESS_CACHE_TTL_MS = 30_000;
57
42
  /**
58
- * Tracks the latest updated_at seen on the `__screen_refresh__` key in
59
- * application_state. Bumped when the agent calls the `refresh-screen` tool,
60
- * and surfaced as a distinct `screen-refresh` event so clients can remount
61
- * the main content subtree via React key.
62
- *
63
- * `_screenRefreshInitialized` guards against spurious emits on the first
64
- * poll after a restart (where an existing row would look like a fresh bump).
65
- * Once we've taken a baseline reading, any subsequent increase emits.
43
+ * Shorter TTL for a denied (false) entry so a transient DB error (which we
44
+ * fail-closed on) doesn't lock a legitimate user out of the push path for the
45
+ * full 30s they recover on their next event after this window.
66
46
  */
67
- let _lastScreenRefreshTs = 0;
68
- let _screenRefreshInitialized = false;
69
- // Per-session high-water marks for `__screen_refresh__`. Each user's row is
70
- // tracked independently so a refresh triggered by one user only remounts that
71
- // user's screen (owner-scoped), never every authenticated poller.
72
- const _lastScreenRefreshTsBySession = new Map();
47
+ const ACCESS_CACHE_DENY_TTL_MS = 5_000;
48
+ /** Max cache entries before FIFO eviction kicks in. */
49
+ const ACCESS_CACHE_MAX = 500;
73
50
  const SCREEN_REFRESH_KEY = "__screen_refresh__";
74
- let _localEmittersWired = false;
75
- function wireLocalEmitters() {
76
- if (_localEmittersWired)
77
- return;
78
- _localEmittersWired = true;
79
- getAppStateEmitter().on("app-state", (event) => {
80
- if (event.key === EXTENSION_CHANGE_MARKER_KEY ||
81
- event.key === ACTION_CHANGE_MARKER_KEY) {
82
- return;
83
- }
84
- recordChange(event);
85
- });
86
- getSettingsEmitter().on("settings", (event) => {
87
- recordChange(event);
88
- });
89
- }
90
51
  function timestampValue(value) {
91
52
  if (typeof value === "number" && Number.isFinite(value))
92
53
  return value;
@@ -110,110 +71,6 @@ function syncEventsDisabled() {
110
71
  (process.env.VITEST === "true" &&
111
72
  process.env.AGENT_NATIVE_SYNC_EVENTS_ENABLE_IN_TESTS !== "1"));
112
73
  }
113
- async function ensureSyncEventsTable() {
114
- if (syncEventsDisabled())
115
- return false;
116
- if (!_syncEventsInitPromise) {
117
- _syncEventsInitPromise = (async () => {
118
- const client = getDbExec();
119
- const createSql = `
120
- CREATE TABLE IF NOT EXISTS sync_events (
121
- id TEXT PRIMARY KEY,
122
- version BIGINT NOT NULL,
123
- event_json TEXT NOT NULL,
124
- source TEXT NOT NULL,
125
- type TEXT NOT NULL,
126
- event_key TEXT,
127
- owner TEXT,
128
- org_id TEXT,
129
- resource_type TEXT,
130
- resource_id TEXT,
131
- created_at BIGINT NOT NULL
132
- )
133
- `;
134
- if (isPostgres()) {
135
- await ensureTableExists("sync_events", createSql);
136
- await ensureIndexExists("sync_events_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)");
137
- await ensureIndexExists("sync_events_owner_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)");
138
- await ensureIndexExists("sync_events_org_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)");
139
- return true;
140
- }
141
- await client.execute(createSql);
142
- for (const ddl of [
143
- "CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
144
- "CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
145
- "CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
146
- ]) {
147
- try {
148
- await client.execute(ddl);
149
- }
150
- catch {
151
- // Index already exists or the dialect rejected a duplicate.
152
- }
153
- }
154
- return true;
155
- })().catch(() => {
156
- _syncEventsInitPromise = undefined;
157
- return false;
158
- });
159
- }
160
- return _syncEventsInitPromise;
161
- }
162
- function durableEventId(version) {
163
- return `${version}-${Math.random().toString(36).slice(2, 10)}`;
164
- }
165
- async function pruneDurableEvents(client) {
166
- const now = Date.now();
167
- if (now - _lastDurablePrune < 5 * 60 * 1000)
168
- return;
169
- _lastDurablePrune = now;
170
- await client
171
- .execute({
172
- sql: "DELETE FROM sync_events WHERE created_at < ?",
173
- args: [now - DURABLE_RETENTION_MS],
174
- })
175
- .catch(() => { });
176
- }
177
- async function persistSyncEvent(event) {
178
- if (!(await ensureSyncEventsTable()))
179
- return;
180
- const client = getDbExec();
181
- await client
182
- .execute({
183
- sql: isPostgres()
184
- ? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
185
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
186
- ON CONFLICT (id) DO NOTHING`
187
- : `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
188
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
189
- args: [
190
- durableEventId(event.version),
191
- event.version,
192
- JSON.stringify(event),
193
- event.source,
194
- event.type,
195
- event.key ?? null,
196
- event.owner ?? null,
197
- event.orgId ?? null,
198
- event.resourceType ?? null,
199
- event.resourceId ?? null,
200
- Date.now(),
201
- ],
202
- })
203
- .catch(() => { });
204
- await pruneDurableEvents(client);
205
- }
206
- async function readMaxSyncEventVersion() {
207
- if (!(await ensureSyncEventsTable()))
208
- return 0;
209
- try {
210
- const result = await getDbExec().execute("SELECT MAX(version) as max_version FROM sync_events");
211
- return timestampValue(result.rows[0]?.max_version);
212
- }
213
- catch {
214
- return 0;
215
- }
216
- }
217
74
  async function readMaxUpdatedAtRaw(db, table) {
218
75
  try {
219
76
  const result = await db.execute(`SELECT MAX(updated_at) as max_ts FROM ${table}`);
@@ -251,37 +108,12 @@ async function readActionMarkerMaxUpdatedAt(db) {
251
108
  return 0;
252
109
  }
253
110
  }
254
- /** Get the current global version counter. */
255
- export function getVersion() {
256
- return _version;
257
- }
258
- export function getPollEmitter() {
259
- return _pollEmitter;
260
- }
261
- /**
262
- * In-memory, TTL'd access cache for the access-aware branch of
263
- * `canSeeChangeForUser`. Keyed by `${userEmail}|${resourceType}|${resourceId}`.
264
- *
265
- * Insertion order doubles as FIFO for eviction (JS Maps preserve insertion
266
- * order), so we can evict the oldest entries when we exceed the cap.
267
- */
268
- const _accessCache = new Map();
269
- /** In-flight background access checks, keyed identically, to dedupe bursts. */
270
- const _accessInFlight = new Set();
271
- /** Per-resource generation bumped when shares/visibility change. */
272
- const _accessInvalidationEpoch = new Map();
273
- /** TTL for an allowed (true) cache entry. */
274
- const ACCESS_CACHE_TTL_MS = 30_000;
275
- /**
276
- * Shorter TTL for a denied (false) entry so a transient DB error (which we
277
- * fail-closed on) doesn't lock a legitimate user out of the push path for the
278
- * full 30s — they recover on their next event after this window.
279
- */
280
- const ACCESS_CACHE_DENY_TTL_MS = 5_000;
281
- /** Max cache entries before FIFO eviction kicks in. */
282
- const ACCESS_CACHE_MAX = 500;
283
- function accessCacheKey(userEmail, resourceType, resourceId) {
284
- return `${userEmail}|${resourceType}|${resourceId}`;
111
+ function accessCacheKey(userEmail, orgId, resourceType, resourceId) {
112
+ // orgId is part of the key: org visibility and org shares are org-scoped, so a
113
+ // user in multiple orgs must not reuse an org-A decision under an org-B
114
+ // session. The trailing `|resourceType|resourceId` still lets
115
+ // invalidateCollabAccessCache match by suffix.
116
+ return `${userEmail}|${orgId ?? ""}|${resourceType}|${resourceId}`;
285
117
  }
286
118
  function accessResourceKey(resourceType, resourceId) {
287
119
  return `${resourceType}|${resourceId}`;
@@ -289,148 +121,6 @@ function accessResourceKey(resourceType, resourceId) {
289
121
  function accessCacheTtl(allowed) {
290
122
  return allowed ? ACCESS_CACHE_TTL_MS : ACCESS_CACHE_DENY_TTL_MS;
291
123
  }
292
- export function invalidateCollabAccessCache(resourceType, resourceId) {
293
- const resourceKey = accessResourceKey(resourceType, resourceId);
294
- _accessInvalidationEpoch.set(resourceKey, (_accessInvalidationEpoch.get(resourceKey) ?? 0) + 1);
295
- const suffix = `|${resourceKey}`;
296
- for (const key of Array.from(_accessCache.keys())) {
297
- if (key.endsWith(suffix))
298
- _accessCache.delete(key);
299
- }
300
- for (const key of Array.from(_accessInFlight)) {
301
- if (key.endsWith(suffix))
302
- _accessInFlight.delete(key);
303
- }
304
- }
305
- function setAccessCache(key, allowed, now) {
306
- // Re-insert so the key moves to the end (most-recent) for FIFO ordering.
307
- _accessCache.delete(key);
308
- _accessCache.set(key, { allowed, checkedAt: now });
309
- if (_accessCache.size > ACCESS_CACHE_MAX) {
310
- // Evict the oldest entries (front of insertion order) back under the cap.
311
- const overflow = _accessCache.size - ACCESS_CACHE_MAX;
312
- let removed = 0;
313
- for (const oldestKey of _accessCache.keys()) {
314
- _accessCache.delete(oldestKey);
315
- if (++removed >= overflow)
316
- break;
317
- }
318
- }
319
- }
320
- /**
321
- * Fire a background access check for a cache-miss key. Never awaited by the
322
- * caller — the current event is NOT delivered (we returned false), but the
323
- * result is cached so the user's NEXT event within the TTL is pushed. Dedupes
324
- * concurrent checks for the same key via `_accessInFlight`.
325
- */
326
- function scheduleAccessCheck(key, resourceType, resourceId, userEmail, orgId) {
327
- if (_accessInFlight.has(key))
328
- return;
329
- _accessInFlight.add(key);
330
- const resourceKey = accessResourceKey(resourceType, resourceId);
331
- const epoch = _accessInvalidationEpoch.get(resourceKey) ?? 0;
332
- void (async () => {
333
- try {
334
- // Dynamic import to avoid a load-order/circular-import hazard: poll.ts is
335
- // imported very widely, and the sharing/access module pulls in the
336
- // resource registry. Importing it lazily inside this background function
337
- // keeps the module graph acyclic at load time.
338
- const { resolveAccess } = await import("../sharing/access.js");
339
- const access = await resolveAccess(resourceType, resourceId, {
340
- userEmail,
341
- orgId,
342
- });
343
- if ((_accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch)
344
- return;
345
- setAccessCache(key, access != null, Date.now());
346
- }
347
- catch {
348
- // Fail closed on any error (DB not ready, missing registration, etc.),
349
- // but with the short deny TTL so a transient failure self-heals quickly.
350
- if ((_accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch)
351
- return;
352
- setAccessCache(key, false, Date.now());
353
- }
354
- finally {
355
- _accessInFlight.delete(key);
356
- }
357
- })();
358
- }
359
- /**
360
- * Test-only: clear the access cache and in-flight set so cases don't bleed
361
- * into each other. Underscore-prefixed and intentionally NOT part of the
362
- * public API — do not rely on it outside tests.
363
- */
364
- export function __resetCollabAccessCacheForTests() {
365
- _accessCache.clear();
366
- _accessInFlight.clear();
367
- _accessInvalidationEpoch.clear();
368
- }
369
- /**
370
- * Decide whether a poll/SSE change event should be delivered to a user.
371
- *
372
- * SYNC-CACHE VARIANT — WHY THIS IS SYNCHRONOUS:
373
- * This function is called on hot, synchronous paths: the SSE emitter callback
374
- * `push(change)` in poll-events.ts (fires per event) and the
375
- * `getChangesSinceForUser` loop in this file. Making it async would be
376
- * invasive (it would ripple through both call sites and their emitters).
377
- * Instead, for the access-aware branch we consult an in-memory cache and, on a
378
- * miss, fire a NON-BLOCKING background access check and return `false` for the
379
- * current event. Because the poll fallback (`getChangesSinceForUser` on the
380
- * next `/poll` cycle) re-evaluates with the now-populated cache, delivery is
381
- * eventually guaranteed — the only cost is that the very first event for a
382
- * fresh (user, resource) pair goes over poll instead of push, and every
383
- * subsequent event within the TTL is pushed.
384
- *
385
- * Security: a cache MISS returns `false`, so we NEVER deliver to a user before
386
- * their access has been affirmatively confirmed by `resolveAccess` — the same
387
- * authority that gates the HTTP routes. Errors fail closed (cached deny). The
388
- * owner/org fast paths below are unchanged and evaluated first.
389
- */
390
- export function canSeeChangeForUser(event, userEmail, orgId) {
391
- return getChangeVisibilityForUser(event, userEmail, orgId) === "visible";
392
- }
393
- function getChangeVisibilityForUser(event, userEmail, orgId) {
394
- // Global / unowned events: every authenticated user gets them. Events that
395
- // predate resource tagging (owner/org only, no resourceType) keep the exact
396
- // conservative contract they had before.
397
- if (!event.owner && !event.orgId && !event.resourceType)
398
- return "visible";
399
- if (event.owner && event.owner === userEmail)
400
- return "visible";
401
- if (event.orgId && orgId && event.orgId === orgId)
402
- return "visible";
403
- // Access-aware branch: only when the event carries BOTH resourceType and
404
- // resourceId and the owner/org fast paths above did not already grant.
405
- if (event.resourceType && event.resourceId) {
406
- const key = accessCacheKey(userEmail, event.resourceType, event.resourceId);
407
- const cached = _accessCache.get(key);
408
- const now = Date.now();
409
- if (cached && now - cached.checkedAt < accessCacheTtl(cached.allowed)) {
410
- // Fresh, non-expired cache hit → trust the cached decision.
411
- return cached.allowed ? "visible" : "hidden";
412
- }
413
- // Miss or expired: do NOT deliver this event, but schedule the async check
414
- // so the user's next event (or poll cycle) resolves correctly.
415
- scheduleAccessCheck(key, event.resourceType, event.resourceId, userEmail, orgId);
416
- return "pending";
417
- }
418
- return "hidden";
419
- }
420
- /** Record a change event. Called by emitter listeners. */
421
- export function recordChange(event) {
422
- // Use timestamp-aligned versions so all serverless instances produce
423
- // values in the same range (seeded from DB, then incremented via
424
- // Date.now). Plain ++counter diverges across cold starts.
425
- _version = Math.max(_version + 1, Date.now());
426
- const entry = { ...event, version: _version };
427
- _buffer.push(entry);
428
- if (_buffer.length > MAX_BUFFER) {
429
- _buffer.splice(0, _buffer.length - MAX_BUFFER);
430
- }
431
- _pollEmitter.emit(POLL_CHANGE_EVENT, entry);
432
- void persistSyncEvent(entry);
433
- }
434
124
  function extensionTargetKey(target) {
435
125
  if (target.owner)
436
126
  return `owner:${target.owner}`;
@@ -443,32 +133,6 @@ function addExtensionTarget(targets, target) {
443
133
  if (key)
444
134
  targets.set(key, target);
445
135
  }
446
- function recordExtensionChanges(targets) {
447
- const uniqueTargets = new Map();
448
- for (const target of targets)
449
- addExtensionTarget(uniqueTargets, target);
450
- for (const target of uniqueTargets.values()) {
451
- recordChange({
452
- source: "extensions",
453
- type: "change",
454
- key: "*",
455
- ...(target.owner ? { owner: target.owner } : {}),
456
- ...(target.orgId ? { orgId: target.orgId } : {}),
457
- });
458
- }
459
- }
460
- function recordActionChanges(targets) {
461
- for (const target of targets) {
462
- recordChange({
463
- source: "action",
464
- type: "change",
465
- key: target.actionName ?? "*",
466
- ...(target.owner ? { owner: target.owner } : {}),
467
- ...(target.orgId ? { orgId: target.orgId } : {}),
468
- ...(target.requestSource ? { requestSource: target.requestSource } : {}),
469
- });
470
- }
471
- }
472
136
  function extensionTargetsForRow(row, shareRows) {
473
137
  const targets = new Map();
474
138
  const owner = typeof row.owner_email === "string" ? row.owner_email : "";
@@ -517,421 +181,866 @@ async function readExtensionTargetsForRows(db, rows) {
517
181
  }
518
182
  return rows.map((row) => extensionTargetsForRow(row, sharesByResourceId.get(typeof row.id === "string" ? row.id : "") ?? []));
519
183
  }
520
- /** Get all changes after a given version. */
521
- export function getChangesSince(since) {
522
- if (since >= _version) {
523
- return { version: _version, events: [] };
524
- }
525
- const events = _buffer.filter((e) => e.version > since);
526
- return { version: _version, events };
527
- }
184
+ const defaultResolveAccess = async (resourceType, resourceId, ctx) => {
185
+ const { resolveAccess } = await import("../sharing/access.js");
186
+ return resolveAccess(resourceType, resourceId, ctx);
187
+ };
528
188
  /**
529
- * Get changes after a given version, filtered to events the caller is
530
- * allowed to see.
531
- *
532
- * Filtering rules:
533
- * - Events without an `owner`/`orgId`/`resourceType` are deployment-global
534
- * (table-level pings, screen-refresh, etc.) and visible to every
535
- * authenticated user.
536
- * - Events with `owner === userEmail` go to that user.
537
- * - Events with `orgId === orgId` go to anyone in that org.
538
- * - Events carrying `resourceType` + `resourceId` additionally reach explicit
539
- * viewer+ sharees via the access-aware cache in `canSeeChangeForUser`.
540
- * - All other owned events are filtered out.
189
+ * Per-app change-tracking state and read path. One instance per deployed app.
190
+ * The framework runs a single default instance ({@link getDefaultAppSyncState})
191
+ * bound to the process DB; the hosted Realtime Gateway constructs one instance
192
+ * per app with that app's pooled Neon connection injected.
541
193
  */
542
- export function getChangesSinceForUser(since, userEmail, orgId) {
543
- if (since >= _version) {
544
- return { version: _version, events: [] };
545
- }
546
- const events = [];
547
- let version = _version;
548
- for (const event of _buffer) {
549
- if (event.version <= since)
550
- continue;
551
- const visibility = getChangeVisibilityForUser(event, userEmail, orgId);
552
- if (visibility === "visible") {
553
- events.push(event);
554
- continue;
555
- }
556
- if (visibility === "pending") {
557
- version = Math.max(since, event.version - 1);
558
- return { version, events, cursorLimited: true };
194
+ export class AppSyncState {
195
+ getDb;
196
+ isPg;
197
+ resolveAccessFn;
198
+ deterministicEventIds;
199
+ // Timestamp-aligned versions so all serverless instances produce values in
200
+ // the same range (seeded from DB, then incremented via Date.now). Plain
201
+ // ++counter diverges across cold starts.
202
+ version = 0;
203
+ buffer = [];
204
+ pollEmitter = new EventEmitter();
205
+ syncEventsInitPromise;
206
+ lastDurablePrune = 0;
207
+ /**
208
+ * Whether we've seeded `version` from the DB. In serverless (Netlify,
209
+ * Vercel, etc.) each invocation starts fresh — without seeding, `version`
210
+ * resets to 0 and polling clients see the version jump backwards, causing
211
+ * duplicate events and stuck UI.
212
+ */
213
+ versionSeeded = false;
214
+ lastDbCheck = 0;
215
+ // Coalesces concurrent checkExternalDbChanges runs. The throttle alone does
216
+ // not prevent overlap when a single check takes longer than the interval —
217
+ // two overlapping runs would each read+advance the watermarks and double-emit.
218
+ checkPromise = null;
219
+ lastAppStateTs = 0;
220
+ lastSettingsTs = 0;
221
+ lastExtensionsTs = 0;
222
+ lastExtensionsUpdatedAt;
223
+ lastExtensionMarkerTs = 0;
224
+ lastActionMarkerTs = 0;
225
+ /**
226
+ * Tracks the latest updated_at seen on the `__screen_refresh__` key.
227
+ * `screenRefreshInitialized` guards against spurious emits on the first poll
228
+ * after a restart (where an existing row would look like a fresh bump).
229
+ */
230
+ lastScreenRefreshTs = 0;
231
+ screenRefreshInitialized = false;
232
+ lastScreenRefreshTsBySession = new Map();
233
+ localEmittersWired = false;
234
+ /**
235
+ * TTL'd access cache for the access-aware branch of `canSeeChangeForUser`,
236
+ * keyed `${userEmail}|${resourceType}|${resourceId}`. Insertion order doubles
237
+ * as FIFO for eviction (JS Maps preserve insertion order). Held per-instance
238
+ * so one gateway process serving many apps never leaks one app's cached
239
+ * access decision into another app's delivery filter.
240
+ */
241
+ accessCache = new Map();
242
+ /** In-flight background access checks, keyed identically, to dedupe bursts. */
243
+ accessInFlight = new Set();
244
+ /** Per-resource generation bumped when shares/visibility change. */
245
+ accessInvalidationEpoch = new Map();
246
+ constructor(options = {}) {
247
+ this.getDb = options.getDb ?? getDbExec;
248
+ this.isPg = options.isPostgres ?? isPostgres;
249
+ this.resolveAccessFn = options.resolveAccess ?? defaultResolveAccess;
250
+ this.deterministicEventIds = options.deterministicEventIds ?? false;
251
+ this.pollEmitter.setMaxListeners(0);
252
+ }
253
+ /**
254
+ * Durable-event id. Deterministic (hash of logical identity + `dedupeKey`,
255
+ * excluding the per-instance `version`) when the instance opts in AND a
256
+ * dedupe signal is supplied — that combination lets `ON CONFLICT (id) DO
257
+ * NOTHING` collapse the same out-of-band write detected by multiple gateway
258
+ * instances into one row. Otherwise the historical `version-<random>` id,
259
+ * which is unique-per-write for a single-writer app.
260
+ */
261
+ durableEventId(event, dedupeKey) {
262
+ if (this.deterministicEventIds && dedupeKey !== undefined) {
263
+ const identity = [
264
+ event.source,
265
+ event.type,
266
+ event.key ?? "",
267
+ event.owner ?? "",
268
+ event.orgId ?? "",
269
+ event.resourceType ?? "",
270
+ event.resourceId ?? "",
271
+ dedupeKey,
272
+ ].join("");
273
+ return createHash("sha256").update(identity).digest("hex").slice(0, 32);
559
274
  }
275
+ return `${event.version}-${Math.random().toString(36).slice(2, 10)}`;
560
276
  }
561
- return { version, events };
562
- }
563
- async function getDurableChangesSinceForUser(since, userEmail, orgId) {
564
- if (since <= 0 || !(await ensureSyncEventsTable())) {
565
- return { version: _version, events: [] };
277
+ /** Get the current version counter. */
278
+ getVersion() {
279
+ return this.version;
566
280
  }
567
- try {
568
- // Scope the fetch to rows that could ever be visible to this caller
569
- // before paying to JSON.parse and visibility-check every deployment-wide
570
- // event: deployment-global rows (no owner, no org), the caller's own
571
- // rows, the caller's org's rows, and resource-scoped rows (owner/org
572
- // don't gate those access is decided below by
573
- // `getChangeVisibilityForUser`'s access-aware branch, which can grant a
574
- // non-owner sharee, so resource-scoped rows must still flow through that
575
- // check regardless of who owns them). This lets Postgres/SQLite use the
576
- // `sync_events_owner_version_idx` / `sync_events_org_version_idx` indexes
577
- // instead of scanning every tenant's activity on every poll. The OR group
578
- // is parenthesized so `version > ?` ANDs against the whole group, not
579
- // just the first term. A caller with no org passes a null `orgId` bind
580
- // param, which makes `org_id = ?` evaluate to no match for every row
581
- // (including null-org rows) in both dialects — mirroring the
582
- // `event.orgId && orgId` truthy check in `getChangeVisibilityForUser`.
583
- const result = await getDbExec().execute({
584
- sql: `SELECT version, event_json FROM sync_events WHERE version > ?
585
- AND (
586
- (owner IS NULL AND org_id IS NULL)
587
- OR owner = ?
588
- OR org_id = ?
589
- OR resource_type IS NOT NULL
590
- )
591
- ORDER BY version ASC LIMIT ?`,
592
- args: [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
281
+ getPollEmitter() {
282
+ return this.pollEmitter;
283
+ }
284
+ /**
285
+ * Wire the in-process app-state/settings emitters into `recordChange` so
286
+ * same-process writes surface on the poll/SSE fast path. Idempotent. Only the
287
+ * default (in-process) instance calls this the gateway learns of changes by
288
+ * tailing the DB, not from these process-global emitters.
289
+ */
290
+ wireLocalEmitters() {
291
+ if (this.localEmittersWired)
292
+ return;
293
+ this.localEmittersWired = true;
294
+ getAppStateEmitter().on("app-state", (event) => {
295
+ if (event.key === EXTENSION_CHANGE_MARKER_KEY ||
296
+ event.key === ACTION_CHANGE_MARKER_KEY) {
297
+ return;
298
+ }
299
+ this.recordChange(event);
593
300
  });
594
- const events = [];
595
- let version = Math.max(_version, since);
596
- let lastDurableVersion = since;
597
- const rows = result.rows.slice(0, DURABLE_READ_LIMIT);
598
- const overflowVersion = timestampValue(result.rows[DURABLE_READ_LIMIT]?.version);
599
- for (const row of rows) {
600
- const rawVersion = timestampValue(row.version);
601
- if (rawVersion > lastDurableVersion)
602
- lastDurableVersion = rawVersion;
603
- if (rawVersion > version)
604
- version = rawVersion;
605
- let event = null;
606
- try {
607
- const parsed = JSON.parse(String(row.event_json));
608
- if (parsed &&
609
- typeof parsed === "object" &&
610
- typeof parsed.source === "string" &&
611
- typeof parsed.type === "string") {
612
- event = {
613
- ...parsed,
614
- version: rawVersion || parsed.version,
301
+ getSettingsEmitter().on("settings", (event) => {
302
+ this.recordChange(event);
303
+ });
304
+ }
305
+ async ensureSyncEventsTable() {
306
+ if (syncEventsDisabled())
307
+ return false;
308
+ if (!this.syncEventsInitPromise) {
309
+ this.syncEventsInitPromise = (async () => {
310
+ const client = this.getDb();
311
+ const createSql = `
312
+ CREATE TABLE IF NOT EXISTS sync_events (
313
+ id TEXT PRIMARY KEY,
314
+ version BIGINT NOT NULL,
315
+ event_json TEXT NOT NULL,
316
+ source TEXT NOT NULL,
317
+ type TEXT NOT NULL,
318
+ event_key TEXT,
319
+ owner TEXT,
320
+ org_id TEXT,
321
+ resource_type TEXT,
322
+ resource_id TEXT,
323
+ created_at BIGINT NOT NULL
324
+ )
325
+ `;
326
+ if (this.isPg()) {
327
+ // Run DDL against THIS app's DB, not the process-global one — the
328
+ // gateway injects a per-app getDb, and ddl-guard otherwise probes/
329
+ // creates via the global exec. The dialect override matters for the
330
+ // same reason: ddl-guard's own isPostgres() reads the process-global
331
+ // DB config, which in a gateway process is not this app's dialect.
332
+ const guardOptions = {
333
+ injectedClient: client,
334
+ dialectIsPostgres: true,
615
335
  };
336
+ await ensureTableExists("sync_events", createSql, guardOptions);
337
+ await ensureIndexExists("sync_events_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)", guardOptions);
338
+ await ensureIndexExists("sync_events_owner_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)", guardOptions);
339
+ await ensureIndexExists("sync_events_org_version_idx", "CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)", guardOptions);
340
+ return true;
341
+ }
342
+ await client.execute(createSql);
343
+ for (const ddl of [
344
+ "CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
345
+ "CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
346
+ "CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
347
+ ]) {
348
+ try {
349
+ await client.execute(ddl);
350
+ }
351
+ catch {
352
+ // Index already exists or the dialect rejected a duplicate.
353
+ }
354
+ }
355
+ return true;
356
+ })().catch(() => {
357
+ this.syncEventsInitPromise = undefined;
358
+ return false;
359
+ });
360
+ }
361
+ return this.syncEventsInitPromise;
362
+ }
363
+ async pruneDurableEvents(client) {
364
+ const now = Date.now();
365
+ if (now - this.lastDurablePrune < 5 * 60 * 1000)
366
+ return;
367
+ this.lastDurablePrune = now;
368
+ await client
369
+ .execute({
370
+ sql: "DELETE FROM sync_events WHERE created_at < ?",
371
+ args: [now - DURABLE_RETENTION_MS],
372
+ })
373
+ .catch(() => { });
374
+ }
375
+ async persistSyncEvent(event, dedupeKey) {
376
+ if (!(await this.ensureSyncEventsTable()))
377
+ return;
378
+ const client = this.getDb();
379
+ await client
380
+ .execute({
381
+ sql: this.isPg()
382
+ ? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
383
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
384
+ ON CONFLICT (id) DO NOTHING`
385
+ : `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
386
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
387
+ args: [
388
+ this.durableEventId(event, dedupeKey),
389
+ event.version,
390
+ JSON.stringify(event),
391
+ event.source,
392
+ event.type,
393
+ event.key ?? null,
394
+ event.owner ?? null,
395
+ event.orgId ?? null,
396
+ event.resourceType ?? null,
397
+ event.resourceId ?? null,
398
+ Date.now(),
399
+ ],
400
+ })
401
+ .catch(() => { });
402
+ await this.pruneDurableEvents(client);
403
+ }
404
+ async readMaxSyncEventVersion() {
405
+ if (!(await this.ensureSyncEventsTable()))
406
+ return 0;
407
+ try {
408
+ const result = await this.getDb().execute("SELECT MAX(version) as max_version FROM sync_events");
409
+ return timestampValue(result.rows[0]?.max_version);
410
+ }
411
+ catch {
412
+ return 0;
413
+ }
414
+ }
415
+ /**
416
+ * Oldest retained durable version. Used to detect a reconnect cursor that
417
+ * predates the 24h retention window so the gateway can signal a full
418
+ * resync instead of silently dropping events. One indexed aggregate over
419
+ * `sync_events_version_idx`; no schema change.
420
+ */
421
+ async readMinSyncEventVersion() {
422
+ if (!(await this.ensureSyncEventsTable()))
423
+ return 0;
424
+ try {
425
+ const result = await this.getDb().execute("SELECT MIN(version) as min_version FROM sync_events");
426
+ return timestampValue(result.rows[0]?.min_version);
427
+ }
428
+ catch {
429
+ return 0;
430
+ }
431
+ }
432
+ invalidateCollabAccessCache(resourceType, resourceId) {
433
+ const resourceKey = accessResourceKey(resourceType, resourceId);
434
+ this.accessInvalidationEpoch.set(resourceKey, (this.accessInvalidationEpoch.get(resourceKey) ?? 0) + 1);
435
+ const suffix = `|${resourceKey}`;
436
+ for (const key of Array.from(this.accessCache.keys())) {
437
+ if (key.endsWith(suffix))
438
+ this.accessCache.delete(key);
439
+ }
440
+ for (const key of Array.from(this.accessInFlight)) {
441
+ if (key.endsWith(suffix))
442
+ this.accessInFlight.delete(key);
443
+ }
444
+ }
445
+ setAccessCache(key, allowed, now) {
446
+ // Re-insert so the key moves to the end (most-recent) for FIFO ordering.
447
+ this.accessCache.delete(key);
448
+ this.accessCache.set(key, { allowed, checkedAt: now });
449
+ if (this.accessCache.size > ACCESS_CACHE_MAX) {
450
+ // Evict the oldest entries (front of insertion order) back under the cap.
451
+ const overflow = this.accessCache.size - ACCESS_CACHE_MAX;
452
+ let removed = 0;
453
+ for (const oldestKey of this.accessCache.keys()) {
454
+ this.accessCache.delete(oldestKey);
455
+ if (++removed >= overflow)
456
+ break;
457
+ }
458
+ }
459
+ }
460
+ /**
461
+ * Fire a background access check for a cache-miss key. Never awaited by the
462
+ * caller — the current event is NOT delivered (we returned false), but the
463
+ * result is cached so the user's NEXT event within the TTL is pushed. Dedupes
464
+ * concurrent checks for the same key via `accessInFlight`.
465
+ */
466
+ scheduleAccessCheck(key, resourceType, resourceId, userEmail, orgId) {
467
+ if (this.accessInFlight.has(key))
468
+ return;
469
+ this.accessInFlight.add(key);
470
+ const resourceKey = accessResourceKey(resourceType, resourceId);
471
+ const epoch = this.accessInvalidationEpoch.get(resourceKey) ?? 0;
472
+ void (async () => {
473
+ try {
474
+ const access = await this.resolveAccessFn(resourceType, resourceId, {
475
+ userEmail,
476
+ orgId,
477
+ });
478
+ if ((this.accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) {
479
+ return;
616
480
  }
481
+ this.setAccessCache(key, access != null, Date.now());
617
482
  }
618
483
  catch {
619
- event = null;
484
+ // Fail closed on any error (DB not ready, missing registration, etc.),
485
+ // but with the short deny TTL so a transient failure self-heals quickly.
486
+ if ((this.accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) {
487
+ return;
488
+ }
489
+ this.setAccessCache(key, false, Date.now());
490
+ }
491
+ finally {
492
+ this.accessInFlight.delete(key);
493
+ }
494
+ })();
495
+ }
496
+ /**
497
+ * Test-only: clear the access cache and in-flight set so cases don't bleed
498
+ * into each other. Intentionally NOT part of the public API.
499
+ */
500
+ __resetAccessCacheForTests() {
501
+ this.accessCache.clear();
502
+ this.accessInFlight.clear();
503
+ this.accessInvalidationEpoch.clear();
504
+ }
505
+ /**
506
+ * Decide whether a poll/SSE change event should be delivered to a user.
507
+ *
508
+ * SYNC-CACHE VARIANT — WHY THIS IS SYNCHRONOUS:
509
+ * This function is called on hot, synchronous paths: the SSE emitter callback
510
+ * `push(change)` in poll-events.ts (fires per event) and the
511
+ * `getChangesSinceForUser` loop in this file. Making it async would be
512
+ * invasive. Instead, for the access-aware branch we consult an in-memory
513
+ * cache and, on a miss, fire a NON-BLOCKING background access check and
514
+ * return `false` for the current event. Because the poll fallback re-evaluates
515
+ * with the now-populated cache, delivery is eventually guaranteed — the only
516
+ * cost is that the very first event for a fresh (user, resource) pair goes
517
+ * over poll instead of push, and every subsequent event within the TTL is
518
+ * pushed.
519
+ *
520
+ * Security: a cache MISS returns `false`, so we NEVER deliver to a user before
521
+ * their access has been affirmatively confirmed by the resolver — the same
522
+ * authority that gates the HTTP routes. Errors fail closed (cached deny). The
523
+ * owner/org fast paths below are unchanged and evaluated first.
524
+ */
525
+ canSeeChangeForUser(event, userEmail, orgId) {
526
+ return (this.getChangeVisibilityForUser(event, userEmail, orgId) === "visible");
527
+ }
528
+ getChangeVisibilityForUser(event, userEmail, orgId) {
529
+ // Global / unowned events: every authenticated user gets them. Events that
530
+ // predate resource tagging (owner/org only, no resourceType) keep the exact
531
+ // conservative contract they had before.
532
+ if (!event.owner && !event.orgId && !event.resourceType)
533
+ return "visible";
534
+ if (event.owner && event.owner === userEmail)
535
+ return "visible";
536
+ if (event.orgId && orgId && event.orgId === orgId)
537
+ return "visible";
538
+ // Access-aware branch: only when the event carries BOTH resourceType and
539
+ // resourceId and the owner/org fast paths above did not already grant.
540
+ if (event.resourceType && event.resourceId) {
541
+ const key = accessCacheKey(userEmail, orgId, event.resourceType, event.resourceId);
542
+ const cached = this.accessCache.get(key);
543
+ const now = Date.now();
544
+ if (cached && now - cached.checkedAt < accessCacheTtl(cached.allowed)) {
545
+ // Fresh, non-expired cache hit → trust the cached decision.
546
+ return cached.allowed ? "visible" : "hidden";
620
547
  }
621
- if (!event)
548
+ // Miss or expired: do NOT deliver this event, but schedule the async check
549
+ // so the user's next event (or poll cycle) resolves correctly.
550
+ this.scheduleAccessCheck(key, event.resourceType, event.resourceId, userEmail, orgId);
551
+ return "pending";
552
+ }
553
+ return "hidden";
554
+ }
555
+ /**
556
+ * Record a change event. Called by emitter listeners and the tail detector.
557
+ * `dedupeKey` is a stable cross-instance signal (a source row's `updated_at`)
558
+ * used only when the instance derives deterministic ids; it is a persistence
559
+ * concern and never stored on the event object.
560
+ */
561
+ recordChange(event, opts) {
562
+ this.version = Math.max(this.version + 1, Date.now());
563
+ const entry = { ...event, version: this.version };
564
+ this.buffer.push(entry);
565
+ if (this.buffer.length > MAX_BUFFER) {
566
+ this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
567
+ }
568
+ this.pollEmitter.emit(POLL_CHANGE_EVENT, entry);
569
+ void this.persistSyncEvent(entry, opts?.dedupeKey);
570
+ }
571
+ recordExtensionChanges(targets, dedupeKey) {
572
+ const uniqueTargets = new Map();
573
+ for (const target of targets)
574
+ addExtensionTarget(uniqueTargets, target);
575
+ for (const target of uniqueTargets.values()) {
576
+ this.recordChange({
577
+ source: "extensions",
578
+ type: "change",
579
+ key: "*",
580
+ ...(target.owner ? { owner: target.owner } : {}),
581
+ ...(target.orgId ? { orgId: target.orgId } : {}),
582
+ }, dedupeKey !== undefined
583
+ ? {
584
+ dedupeKey: `${dedupeKey}|${target.owner ?? ""}|${target.orgId ?? ""}`,
585
+ }
586
+ : undefined);
587
+ }
588
+ }
589
+ recordActionChanges(targets, dedupeKey) {
590
+ for (const target of targets) {
591
+ this.recordChange({
592
+ source: "action",
593
+ type: "change",
594
+ key: target.actionName ?? "*",
595
+ ...(target.owner ? { owner: target.owner } : {}),
596
+ ...(target.orgId ? { orgId: target.orgId } : {}),
597
+ ...(target.requestSource
598
+ ? { requestSource: target.requestSource }
599
+ : {}),
600
+ }, dedupeKey !== undefined
601
+ ? {
602
+ dedupeKey: `${dedupeKey}|${target.actionName ?? ""}|${target.owner ?? ""}|${target.orgId ?? ""}`,
603
+ }
604
+ : undefined);
605
+ }
606
+ }
607
+ /** Get all changes after a given version. */
608
+ getChangesSince(since) {
609
+ if (since >= this.version) {
610
+ return { version: this.version, events: [] };
611
+ }
612
+ const events = this.buffer.filter((e) => e.version > since);
613
+ return { version: this.version, events };
614
+ }
615
+ /**
616
+ * Get changes after a given version, filtered to events the caller is
617
+ * allowed to see.
618
+ */
619
+ getChangesSinceForUser(since, userEmail, orgId) {
620
+ if (since >= this.version) {
621
+ return { version: this.version, events: [] };
622
+ }
623
+ const events = [];
624
+ let version = this.version;
625
+ for (const event of this.buffer) {
626
+ if (event.version <= since)
622
627
  continue;
623
- const visibility = getChangeVisibilityForUser(event, userEmail, orgId);
628
+ const visibility = this.getChangeVisibilityForUser(event, userEmail, orgId);
624
629
  if (visibility === "visible") {
625
630
  events.push(event);
626
631
  continue;
627
632
  }
628
633
  if (visibility === "pending") {
629
- return {
630
- version: Math.max(since, event.version - 1),
631
- events,
632
- cursorLimited: true,
633
- };
634
+ version = Math.max(since, event.version - 1);
635
+ return { version, events, cursorLimited: true };
634
636
  }
635
637
  }
636
- if (rows.length >= DURABLE_READ_LIMIT) {
637
- if (overflowVersion === lastDurableVersion) {
638
- const boundaryVersion = lastDurableVersion;
638
+ return { version, events };
639
+ }
640
+ async getDurableChangesSinceForUser(since, userEmail, orgId) {
641
+ if (since <= 0 || !(await this.ensureSyncEventsTable())) {
642
+ return { version: this.version, events: [] };
643
+ }
644
+ try {
645
+ // Scope the fetch to rows that could ever be visible to this caller
646
+ // before paying to JSON.parse and visibility-check every deployment-wide
647
+ // event: deployment-global rows (no owner, no org), the caller's own
648
+ // rows, the caller's org's rows, and resource-scoped rows (access is
649
+ // decided below by the access-aware branch, which can grant a non-owner
650
+ // sharee, so resource-scoped rows must still flow through that check
651
+ // regardless of who owns them). A caller with no org passes a null
652
+ // `orgId` bind param, which makes `org_id = ?` match no row in both
653
+ // dialects — mirroring the `event.orgId && orgId` truthy check.
654
+ const result = await this.getDb().execute({
655
+ sql: `SELECT version, event_json FROM sync_events WHERE version > ?
656
+ AND (
657
+ (owner IS NULL AND org_id IS NULL)
658
+ OR owner = ?
659
+ OR org_id = ?
660
+ OR resource_type IS NOT NULL
661
+ )
662
+ ORDER BY version ASC LIMIT ?`,
663
+ args: [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
664
+ });
665
+ const events = [];
666
+ let version = Math.max(this.version, since);
667
+ let lastDurableVersion = since;
668
+ const rows = result.rows.slice(0, DURABLE_READ_LIMIT);
669
+ const overflowVersion = timestampValue(result.rows[DURABLE_READ_LIMIT]?.version);
670
+ for (const row of rows) {
671
+ const rawVersion = timestampValue(row.version);
672
+ if (rawVersion > lastDurableVersion)
673
+ lastDurableVersion = rawVersion;
674
+ if (rawVersion > version)
675
+ version = rawVersion;
676
+ let event = null;
677
+ try {
678
+ const parsed = JSON.parse(String(row.event_json));
679
+ if (parsed &&
680
+ typeof parsed === "object" &&
681
+ typeof parsed.source === "string" &&
682
+ typeof parsed.type === "string") {
683
+ event = {
684
+ ...parsed,
685
+ version: rawVersion || parsed.version,
686
+ };
687
+ }
688
+ }
689
+ catch {
690
+ event = null;
691
+ }
692
+ if (!event)
693
+ continue;
694
+ const visibility = this.getChangeVisibilityForUser(event, userEmail, orgId);
695
+ if (visibility === "visible") {
696
+ events.push(event);
697
+ continue;
698
+ }
699
+ if (visibility === "pending") {
700
+ return {
701
+ version: Math.max(since, event.version - 1),
702
+ events,
703
+ cursorLimited: true,
704
+ };
705
+ }
706
+ }
707
+ if (rows.length >= DURABLE_READ_LIMIT) {
708
+ if (overflowVersion === lastDurableVersion) {
709
+ const boundaryVersion = lastDurableVersion;
710
+ return {
711
+ version: Math.max(since, boundaryVersion - 1),
712
+ events: events.filter((event) => event.version < boundaryVersion),
713
+ cursorLimited: true,
714
+ };
715
+ }
639
716
  return {
640
- version: Math.max(since, boundaryVersion - 1),
641
- events: events.filter((event) => event.version < boundaryVersion),
717
+ version: Math.max(since, lastDurableVersion),
718
+ events,
642
719
  cursorLimited: true,
643
720
  };
644
721
  }
645
- return {
646
- version: Math.max(since, lastDurableVersion),
647
- events,
648
- cursorLimited: true,
649
- };
722
+ return { version, events };
723
+ }
724
+ catch {
725
+ return { version: this.version, events: [] };
650
726
  }
651
- return { version, events };
652
- }
653
- catch {
654
- return { version: _version, events: [] };
655
727
  }
656
- }
657
- async function getCombinedChangesSinceForUser(since, userEmail, orgId, useDurableEvents) {
658
- const memory = getChangesSinceForUser(since, userEmail, orgId);
659
- if (!useDurableEvents)
660
- return memory;
661
- const durable = await getDurableChangesSinceForUser(since, userEmail, orgId);
662
- const byIdentity = new Map();
663
- for (const event of [...durable.events, ...memory.events]) {
664
- byIdentity.set(JSON.stringify([
665
- event.version,
666
- event.source,
667
- event.type,
668
- event.key,
669
- event.owner,
670
- event.orgId,
671
- event.resourceType,
672
- event.resourceId,
673
- ]), event);
674
- }
675
- const events = Array.from(byIdentity.values()).sort((a, b) => a.version - b.version);
676
- const limitedVersions = [memory, durable]
677
- .filter((result) => result.cursorLimited)
678
- .map((result) => result.version);
679
- return {
680
- version: limitedVersions.length > 0
681
- ? Math.min(...limitedVersions)
682
- : Math.max(memory.version, durable.version, since),
683
- events: limitedVersions.length > 0
684
- ? events.filter((event) => event.version <= Math.min(...limitedVersions))
685
- : events,
686
- };
687
- }
688
- /**
689
- * Seed _version from DB timestamps on the first call so serverless
690
- * cold starts don't return version 0 and confuse polling clients.
691
- */
692
- async function seedVersionFromDb() {
693
- if (_versionSeeded)
694
- return;
695
- _versionSeeded = true;
696
- try {
697
- const db = getDbExec();
698
- const [syncEventsTs, appTs, settingsTs, extensionsMaxUpdatedAt, extensionMarkerTs, actionMarkerTs, refreshResult,] = await Promise.all([
699
- readMaxSyncEventVersion(),
700
- readMaxUpdatedAt(db, "application_state"),
701
- readMaxUpdatedAt(db, "settings"),
702
- readMaxUpdatedAtRaw(db, "tools"),
703
- readExtensionMarkerMaxUpdatedAt(db),
704
- readActionMarkerMaxUpdatedAt(db),
705
- db
706
- .execute({
707
- sql: "SELECT session_id, updated_at FROM application_state WHERE key = ?",
708
- args: [SCREEN_REFRESH_KEY],
709
- })
710
- .catch(() => ({ rows: [] })),
711
- ]);
712
- const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
713
- let refreshTs = 0;
714
- for (const row of refreshResult.rows) {
715
- refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
728
+ async getCombinedChangesSinceForUser(since, userEmail, orgId, useDurableEvents) {
729
+ const memory = this.getChangesSinceForUser(since, userEmail, orgId);
730
+ if (!useDurableEvents)
731
+ return memory;
732
+ const durable = await this.getDurableChangesSinceForUser(since, userEmail, orgId);
733
+ const byIdentity = new Map();
734
+ for (const event of [...durable.events, ...memory.events]) {
735
+ byIdentity.set(JSON.stringify([
736
+ event.version,
737
+ event.source,
738
+ event.type,
739
+ event.key,
740
+ event.owner,
741
+ event.orgId,
742
+ event.resourceType,
743
+ event.resourceId,
744
+ ]), event);
716
745
  }
717
- // Seed version never decrease an already-set value
718
- _version = Math.max(_version, syncEventsTs, appTs, settingsTs, extensionsTs, extensionMarkerTs, actionMarkerTs);
719
- // Set baselines so checkExternalDbChanges detects future writes
720
- _lastAppStateTs = appTs;
721
- _lastSettingsTs = settingsTs;
722
- _lastExtensionsTs = extensionsTs;
723
- _lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
724
- _lastExtensionMarkerTs = extensionMarkerTs;
725
- // Action markers are durable specifically so a web server can observe work
726
- // performed by a separate action process. Do not baseline past an existing
727
- // marker on cold start, or the first poll after the action will miss it.
728
- _lastActionMarkerTs = 0;
729
- _lastScreenRefreshTs = refreshTs;
730
- _lastScreenRefreshTsBySession.clear();
731
- for (const row of refreshResult.rows) {
732
- if (typeof row.session_id === "string") {
733
- _lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
746
+ const events = Array.from(byIdentity.values()).sort((a, b) => a.version - b.version);
747
+ const limitedVersions = [memory, durable]
748
+ .filter((result) => result.cursorLimited)
749
+ .map((result) => result.version);
750
+ return {
751
+ version: limitedVersions.length > 0
752
+ ? Math.min(...limitedVersions)
753
+ : Math.max(memory.version, durable.version, since),
754
+ events: limitedVersions.length > 0
755
+ ? events.filter((event) => event.version <= Math.min(...limitedVersions))
756
+ : events,
757
+ };
758
+ }
759
+ /**
760
+ * Seed `version` from DB timestamps on the first call so serverless cold
761
+ * starts don't return version 0 and confuse polling clients.
762
+ */
763
+ async seedVersionFromDb() {
764
+ if (this.versionSeeded)
765
+ return;
766
+ this.versionSeeded = true;
767
+ try {
768
+ const db = this.getDb();
769
+ const [syncEventsTs, appTs, settingsTs, extensionsMaxUpdatedAt, extensionMarkerTs, actionMarkerTs, refreshResult,] = await Promise.all([
770
+ this.readMaxSyncEventVersion(),
771
+ readMaxUpdatedAt(db, "application_state"),
772
+ readMaxUpdatedAt(db, "settings"),
773
+ readMaxUpdatedAtRaw(db, "tools"),
774
+ readExtensionMarkerMaxUpdatedAt(db),
775
+ readActionMarkerMaxUpdatedAt(db),
776
+ db
777
+ .execute({
778
+ sql: "SELECT session_id, updated_at FROM application_state WHERE key = ?",
779
+ args: [SCREEN_REFRESH_KEY],
780
+ })
781
+ .catch(() => ({ rows: [] })),
782
+ ]);
783
+ const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
784
+ let refreshTs = 0;
785
+ for (const row of refreshResult.rows) {
786
+ refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
787
+ }
788
+ // Seed version — never decrease an already-set value
789
+ this.version = Math.max(this.version, syncEventsTs, appTs, settingsTs, extensionsTs, extensionMarkerTs, actionMarkerTs);
790
+ // Set baselines so checkExternalDbChanges detects future writes
791
+ this.lastAppStateTs = appTs;
792
+ this.lastSettingsTs = settingsTs;
793
+ this.lastExtensionsTs = extensionsTs;
794
+ this.lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
795
+ this.lastExtensionMarkerTs = extensionMarkerTs;
796
+ // Action markers are durable specifically so a web server can observe work
797
+ // performed by a separate action process. Do not baseline past an existing
798
+ // marker on cold start, or the first poll after the action will miss it.
799
+ this.lastActionMarkerTs = 0;
800
+ this.lastScreenRefreshTs = refreshTs;
801
+ this.lastScreenRefreshTsBySession.clear();
802
+ for (const row of refreshResult.rows) {
803
+ if (typeof row.session_id === "string") {
804
+ this.lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
805
+ }
734
806
  }
807
+ this.screenRefreshInitialized = true;
808
+ // Skip the redundant cold-start recheck unless there is an existing durable
809
+ // action marker that the first poll still needs to emit.
810
+ this.lastDbCheck = actionMarkerTs > 0 ? 0 : Date.now();
811
+ }
812
+ catch {
813
+ // Tables may not exist yet — ignore
735
814
  }
736
- _screenRefreshInitialized = true;
737
- // Skip the redundant cold-start recheck unless there is an existing durable
738
- // action marker that the first poll still needs to emit.
739
- _lastDbCheck = actionMarkerTs > 0 ? 0 : Date.now();
740
815
  }
741
- catch {
742
- // Tables may not exist yet ignore
816
+ /**
817
+ * Check for cross-process DB writes by comparing updated_at timestamps.
818
+ * Throttled per instance.
819
+ */
820
+ async checkExternalDbChanges(options) {
821
+ const now = Date.now();
822
+ const interval = options.durableEvents
823
+ ? DURABLE_LEGACY_DB_CHECK_INTERVAL_MS
824
+ : LEGACY_DB_CHECK_INTERVAL_MS;
825
+ if (now - this.lastDbCheck < interval)
826
+ return;
827
+ // Coalesce: if a check is already running, await it instead of starting a
828
+ // second overlapping run that would double-advance the watermarks.
829
+ if (this.checkPromise)
830
+ return this.checkPromise;
831
+ this.lastDbCheck = now;
832
+ this.checkPromise = this.doCheckExternalDbChanges().finally(() => {
833
+ this.checkPromise = null;
834
+ });
835
+ return this.checkPromise;
743
836
  }
744
- }
745
- /**
746
- * Check for cross-process DB writes by comparing updated_at timestamps.
747
- * Runs at most once per second to avoid excessive queries.
748
- */
749
- async function checkExternalDbChanges(options) {
750
- const now = Date.now();
751
- const interval = options.durableEvents
752
- ? DURABLE_LEGACY_DB_CHECK_INTERVAL_MS
753
- : LEGACY_DB_CHECK_INTERVAL_MS;
754
- if (now - _lastDbCheck < interval)
755
- return;
756
- // Coalesce: if a check is already running, await it instead of starting a
757
- // second overlapping run that would double-advance the shared watermarks
758
- // (and double-emit change events).
759
- if (_checkPromise)
760
- return _checkPromise;
761
- _lastDbCheck = now;
762
- _checkPromise = doCheckExternalDbChanges().finally(() => {
763
- _checkPromise = null;
764
- });
765
- return _checkPromise;
766
- }
767
- async function doCheckExternalDbChanges() {
768
- try {
769
- const db = getDbExec();
770
- // These reads are independent — each compares the DB against module-level
771
- // high-water marks (`_lastAppStateTs`, etc.) rather than another query's
772
- // result, and none of them mutate state before processing below. On a
773
- // serverless SQL backend every `await` is a network round-trip, so running
774
- // them concurrently shaves stacked latency off every poll cycle. Results
775
- // are still processed in the original sequential order, and conditional
776
- // follow-up queries (action/extension marker detail rows, tool-shares) stay
777
- // sequential within their branch where they depend on these results.
778
- const [appResult, actionMarkerTs, refreshResult, extensionMarkerTs, settingsTs, extensionsMaxUpdatedAt,] = await Promise.all([
779
- db.execute({
780
- sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
781
- args: [_lastAppStateTs],
782
- }),
783
- readActionMarkerMaxUpdatedAt(db),
784
- db.execute({
785
- sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
786
- args: [SCREEN_REFRESH_KEY],
787
- }),
788
- readExtensionMarkerMaxUpdatedAt(db),
789
- readMaxUpdatedAt(db, "settings"),
790
- readMaxUpdatedAtRaw(db, "tools"),
791
- ]);
792
- // Check application_state for external writes. Preserve the changed key so
793
- // clients can invalidate one-shot command queries (`navigate`, `__set_url__`)
794
- // only when those command rows actually change; noisy keys such as
795
- // `slide-fit-check` should not wake navigation readers.
796
- if (appResult.rows.length > 0) {
797
- const appTs = appResult.rows.reduce((max, row) => Math.max(max, timestampValue(row.updated_at)), _lastAppStateTs);
798
- if (_lastAppStateTs > 0) {
799
- for (const row of appResult.rows) {
800
- const key = typeof row.key === "string" ? row.key : "*";
801
- if (key === EXTENSION_CHANGE_MARKER_KEY ||
802
- key === ACTION_CHANGE_MARKER_KEY) {
803
- continue;
837
+ async doCheckExternalDbChanges() {
838
+ try {
839
+ const db = this.getDb();
840
+ // These reads are independent each compares the DB against instance
841
+ // high-water marks rather than another query's result. Run them
842
+ // concurrently to shave stacked latency; results are still processed in
843
+ // the original sequential order, and conditional follow-up queries stay
844
+ // sequential within their branch.
845
+ const [appResult, actionMarkerTs, refreshResult, extensionMarkerTs, settingsTs, extensionsMaxUpdatedAt,] = await Promise.all([
846
+ db.execute({
847
+ sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
848
+ args: [this.lastAppStateTs],
849
+ }),
850
+ readActionMarkerMaxUpdatedAt(db),
851
+ db.execute({
852
+ sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
853
+ args: [SCREEN_REFRESH_KEY],
854
+ }),
855
+ readExtensionMarkerMaxUpdatedAt(db),
856
+ readMaxUpdatedAt(db, "settings"),
857
+ readMaxUpdatedAtRaw(db, "tools"),
858
+ ]);
859
+ // Check application_state for external writes. Preserve the changed key so
860
+ // clients can invalidate one-shot command queries (`navigate`, `__set_url__`)
861
+ // only when those command rows actually change; noisy keys such as
862
+ // `slide-fit-check` should not wake navigation readers.
863
+ if (appResult.rows.length > 0) {
864
+ const appTs = appResult.rows.reduce((max, row) => Math.max(max, timestampValue(row.updated_at)), this.lastAppStateTs);
865
+ if (this.lastAppStateTs > 0) {
866
+ for (const row of appResult.rows) {
867
+ const key = typeof row.key === "string" ? row.key : "*";
868
+ if (key === EXTENSION_CHANGE_MARKER_KEY ||
869
+ key === ACTION_CHANGE_MARKER_KEY) {
870
+ continue;
871
+ }
872
+ const owner = typeof row.session_id === "string" ? row.session_id : undefined;
873
+ this.recordChange({
874
+ source: "app-state",
875
+ type: "change",
876
+ key,
877
+ ...(owner ? { owner } : {}),
878
+ }, { dedupeKey: `app-state|${timestampValue(row.updated_at)}` });
804
879
  }
805
- const owner = typeof row.session_id === "string" ? row.session_id : undefined;
806
- recordChange({
807
- source: "app-state",
808
- type: "change",
809
- key,
810
- ...(owner ? { owner } : {}),
811
- });
812
880
  }
881
+ this.lastAppStateTs = appTs;
813
882
  }
814
- _lastAppStateTs = appTs;
815
- }
816
- // Mutating actions write a durable marker in addition to the in-process
817
- // event. This lets dev-mode `pnpm action ...` child processes and
818
- // serverless action invocations wake the web server's SSE/poll loop as a
819
- // first-class source:"action" event rather than a generic app-state bump.
820
- // `actionMarkerTs` was read above; the detail-row query below is conditional
821
- // on it and depends on its result, so it stays sequential.
822
- if (actionMarkerTs > _lastActionMarkerTs) {
823
- const actionMarkerResult = await db.execute({
824
- sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
825
- args: [ACTION_CHANGE_MARKER_KEY],
826
- });
827
- const changedActionMarkers = actionMarkerResult.rows.filter((row) => timestampValue(row.updated_at) > _lastActionMarkerTs);
828
- recordActionChanges(changedActionMarkers
829
- .map((row) => parseActionChangeMarker(row.session_id, row.value))
830
- .filter((target) => !!target));
831
- _lastActionMarkerTs = actionMarkerTs;
832
- }
833
- // Check for screen-refresh requests from the agent. The `refresh-screen`
834
- // tool writes to application_state under a well-known key; when its
835
- // updated_at bumps, emit a distinct event so the client invalidates
836
- // all queries (not just the ones matching its default queryKey prefix).
837
- // `refreshResult` was read above.
838
- const refreshTs = refreshResult.rows.reduce((max, row) => Math.max(max, timestampValue(row.updated_at)), 0);
839
- if (!_screenRefreshInitialized) {
840
- _lastScreenRefreshTs = refreshTs;
841
- for (const row of refreshResult.rows) {
842
- if (typeof row.session_id === "string") {
843
- _lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
883
+ // Mutating actions write a durable marker in addition to the in-process
884
+ // event. This lets dev-mode `pnpm action ...` child processes and
885
+ // serverless action invocations wake the web server's SSE/poll loop as a
886
+ // first-class source:"action" event rather than a generic app-state bump.
887
+ if (actionMarkerTs > this.lastActionMarkerTs) {
888
+ const actionMarkerResult = await db.execute({
889
+ sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
890
+ args: [ACTION_CHANGE_MARKER_KEY],
891
+ });
892
+ const changedActionMarkers = actionMarkerResult.rows.filter((row) => timestampValue(row.updated_at) > this.lastActionMarkerTs);
893
+ this.recordActionChanges(changedActionMarkers
894
+ .map((row) => parseActionChangeMarker(row.session_id, row.value))
895
+ .filter((target) => !!target), `action|${actionMarkerTs}`);
896
+ this.lastActionMarkerTs = actionMarkerTs;
897
+ }
898
+ // Check for screen-refresh requests from the agent. The `refresh-screen`
899
+ // tool writes to application_state under a well-known key; when its
900
+ // updated_at bumps, emit a distinct event so the client invalidates
901
+ // all queries (not just the ones matching its default queryKey prefix).
902
+ const refreshTs = refreshResult.rows.reduce((max, row) => Math.max(max, timestampValue(row.updated_at)), 0);
903
+ if (!this.screenRefreshInitialized) {
904
+ this.lastScreenRefreshTs = refreshTs;
905
+ for (const row of refreshResult.rows) {
906
+ if (typeof row.session_id === "string") {
907
+ this.lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
908
+ }
844
909
  }
910
+ this.screenRefreshInitialized = true;
845
911
  }
846
- _screenRefreshInitialized = true;
847
- }
848
- else if (refreshTs > _lastScreenRefreshTs) {
849
- // Emit a per-user event only for the session(s) whose row actually
850
- // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
851
- // to that user not every authenticated poller.
852
- for (const row of refreshResult.rows) {
853
- const owner = typeof row.session_id === "string" ? row.session_id : undefined;
854
- if (!owner)
855
- continue;
856
- const rowTs = timestampValue(row.updated_at);
857
- if (rowTs <= (_lastScreenRefreshTsBySession.get(owner) ?? 0))
858
- continue;
859
- let scope;
860
- try {
861
- const raw = row.value;
862
- if (typeof raw === "string") {
863
- const parsed = JSON.parse(raw);
864
- if (typeof parsed?.scope === "string")
865
- scope = parsed.scope;
912
+ else if (refreshTs > this.lastScreenRefreshTs) {
913
+ // Emit a per-user event only for the session(s) whose row actually
914
+ // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
915
+ // to that user not every authenticated poller.
916
+ for (const row of refreshResult.rows) {
917
+ const owner = typeof row.session_id === "string" ? row.session_id : undefined;
918
+ if (!owner)
919
+ continue;
920
+ const rowTs = timestampValue(row.updated_at);
921
+ if (rowTs <= (this.lastScreenRefreshTsBySession.get(owner) ?? 0)) {
922
+ continue;
923
+ }
924
+ let scope;
925
+ try {
926
+ const raw = row.value;
927
+ if (typeof raw === "string") {
928
+ const parsed = JSON.parse(raw);
929
+ if (typeof parsed?.scope === "string")
930
+ scope = parsed.scope;
931
+ }
866
932
  }
933
+ catch { }
934
+ this.recordChange({
935
+ source: "screen-refresh",
936
+ type: "change",
937
+ key: SCREEN_REFRESH_KEY,
938
+ owner,
939
+ ...(scope ? { scope } : {}),
940
+ }, { dedupeKey: `screen-refresh|${rowTs}` });
941
+ this.lastScreenRefreshTsBySession.set(owner, rowTs);
867
942
  }
868
- catch { }
869
- recordChange({
870
- source: "screen-refresh",
871
- type: "change",
872
- key: SCREEN_REFRESH_KEY,
873
- owner,
874
- ...(scope ? { scope } : {}),
943
+ this.lastScreenRefreshTs = refreshTs;
944
+ }
945
+ // Extension mutations write a durable marker row so delete and hide/unhide
946
+ // operations are visible across serverless invocations. Translate those
947
+ // marker rows back into extension-source events for targeted client
948
+ // invalidation while preserving user/org scope.
949
+ if (extensionMarkerTs > this.lastExtensionMarkerTs) {
950
+ const extensionMarkerResult = await db.execute({
951
+ sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
952
+ args: [EXTENSION_CHANGE_MARKER_KEY],
875
953
  });
876
- _lastScreenRefreshTsBySession.set(owner, rowTs);
954
+ const changedExtensionMarkers = extensionMarkerResult.rows.filter((row) => timestampValue(row.updated_at) > this.lastExtensionMarkerTs);
955
+ if (this.lastExtensionMarkerTs > 0) {
956
+ this.recordExtensionChanges(changedExtensionMarkers
957
+ .map((row) => parseExtensionChangeMarker(row.session_id, row.value))
958
+ .filter((target) => !!target), `ext-marker|${extensionMarkerTs}`);
959
+ }
960
+ this.lastExtensionMarkerTs = extensionMarkerTs;
877
961
  }
878
- _lastScreenRefreshTs = refreshTs;
879
- }
880
- // Extension mutations write a durable marker row so delete and hide/unhide
881
- // operations are visible across serverless invocations. Translate those
882
- // marker rows back into extension-source events for targeted client
883
- // invalidation while preserving user/org scope. `extensionMarkerTs` was read
884
- // above; the detail-row query below depends on it and stays sequential.
885
- if (extensionMarkerTs > _lastExtensionMarkerTs) {
886
- const extensionMarkerResult = await db.execute({
887
- sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
888
- args: [EXTENSION_CHANGE_MARKER_KEY],
889
- });
890
- const changedExtensionMarkers = extensionMarkerResult.rows.filter((row) => timestampValue(row.updated_at) > _lastExtensionMarkerTs);
891
- if (_lastExtensionMarkerTs > 0) {
892
- recordExtensionChanges(changedExtensionMarkers
893
- .map((row) => parseExtensionChangeMarker(row.session_id, row.value))
894
- .filter((target) => !!target));
962
+ // Check settings for external writes.
963
+ if (settingsTs > this.lastSettingsTs) {
964
+ if (this.lastSettingsTs > 0) {
965
+ this.recordChange({ source: "settings", type: "change", key: "*" }, { dedupeKey: `settings|${settingsTs}` });
966
+ }
967
+ this.lastSettingsTs = settingsTs;
895
968
  }
896
- _lastExtensionMarkerTs = extensionMarkerTs;
897
- }
898
- // Check settings for external writes. `settingsTs` was read above.
899
- if (settingsTs > _lastSettingsTs) {
900
- if (_lastSettingsTs > 0) {
901
- recordChange({ source: "settings", type: "change", key: "*" });
969
+ // Extension rows live in the legacy physical `tools` table. Keep this as a
970
+ // compatibility fallback for direct table writes, but scope events to the
971
+ // resource owner/share targets instead of broadcasting deployment-wide.
972
+ const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
973
+ if (extensionsTs > this.lastExtensionsTs) {
974
+ const since = this.lastExtensionsUpdatedAt;
975
+ const extensionResult = since === undefined
976
+ ? await db.execute({
977
+ sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools ORDER BY updated_at ASC",
978
+ args: [],
979
+ })
980
+ : await db.execute({
981
+ sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools WHERE updated_at > ? ORDER BY updated_at ASC",
982
+ args: [since],
983
+ });
984
+ const changedExtensionRows = extensionResult.rows.filter((row) => timestampValue(row.updated_at) > this.lastExtensionsTs);
985
+ if (this.lastExtensionsTs > 0) {
986
+ const targetsByRow = await readExtensionTargetsForRows(db, changedExtensionRows);
987
+ targetsByRow.forEach((targets, i) => {
988
+ this.recordExtensionChanges(targets, `ext-tools|${timestampValue(changedExtensionRows[i]?.updated_at)}`);
989
+ });
990
+ }
991
+ this.lastExtensionsTs = extensionsTs;
992
+ this.lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
902
993
  }
903
- _lastSettingsTs = settingsTs;
904
994
  }
905
- // Extension rows live in the legacy physical `tools` table. Keep this as a
906
- // compatibility fallback for direct table writes, but scope events to the
907
- // resource owner/share targets instead of broadcasting deployment-wide.
908
- // `extensionsMaxUpdatedAt` was read above; the per-row query below is
909
- // conditional on `extensionsTs` and stays sequential.
910
- const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
911
- if (extensionsTs > _lastExtensionsTs) {
912
- const since = _lastExtensionsUpdatedAt;
913
- const extensionResult = since === undefined
914
- ? await db.execute({
915
- sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools ORDER BY updated_at ASC",
916
- args: [],
917
- })
918
- : await db.execute({
919
- sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools WHERE updated_at > ? ORDER BY updated_at ASC",
920
- args: [since],
921
- });
922
- const changedExtensionRows = extensionResult.rows.filter((row) => timestampValue(row.updated_at) > _lastExtensionsTs);
923
- if (_lastExtensionsTs > 0) {
924
- const targetsByRow = await readExtensionTargetsForRows(db, changedExtensionRows);
925
- for (const targets of targetsByRow)
926
- recordExtensionChanges(targets);
927
- }
928
- _lastExtensionsTs = extensionsTs;
929
- _lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
995
+ catch {
996
+ // Tables may not exist yet ignore
930
997
  }
931
998
  }
932
- catch {
933
- // Tables may not exist yet — ignore
934
- }
999
+ }
1000
+ let _defaultState;
1001
+ /**
1002
+ * The process-wide default instance, bound to the global DB. All module-level
1003
+ * exports delegate here so self-hosted apps run exactly one code path.
1004
+ */
1005
+ export function getDefaultAppSyncState() {
1006
+ if (!_defaultState)
1007
+ _defaultState = new AppSyncState();
1008
+ return _defaultState;
1009
+ }
1010
+ /** Get the current global version counter. */
1011
+ export function getVersion() {
1012
+ return getDefaultAppSyncState().getVersion();
1013
+ }
1014
+ export function getPollEmitter() {
1015
+ return getDefaultAppSyncState().getPollEmitter();
1016
+ }
1017
+ export function invalidateCollabAccessCache(resourceType, resourceId) {
1018
+ getDefaultAppSyncState().invalidateCollabAccessCache(resourceType, resourceId);
1019
+ }
1020
+ /**
1021
+ * Test-only: clear the default instance's access cache. Underscore-prefixed and
1022
+ * intentionally NOT part of the public API — do not rely on it outside tests.
1023
+ */
1024
+ export function __resetCollabAccessCacheForTests() {
1025
+ getDefaultAppSyncState().__resetAccessCacheForTests();
1026
+ }
1027
+ export function canSeeChangeForUser(event, userEmail, orgId) {
1028
+ return getDefaultAppSyncState().canSeeChangeForUser(event, userEmail, orgId);
1029
+ }
1030
+ /** Record a change event. Called by emitter listeners. */
1031
+ export function recordChange(event) {
1032
+ getDefaultAppSyncState().recordChange(event);
1033
+ }
1034
+ /** Get all changes after a given version. */
1035
+ export function getChangesSince(since) {
1036
+ return getDefaultAppSyncState().getChangesSince(since);
1037
+ }
1038
+ /**
1039
+ * Get changes after a given version, filtered to events the caller is
1040
+ * allowed to see.
1041
+ */
1042
+ export function getChangesSinceForUser(since, userEmail, orgId) {
1043
+ return getDefaultAppSyncState().getChangesSinceForUser(since, userEmail, orgId);
935
1044
  }
936
1045
  /**
937
1046
  * Create an H3 handler for the poll endpoint.
@@ -944,24 +1053,27 @@ async function doCheckExternalDbChanges() {
944
1053
  * Without auth + filtering, an anonymous attacker could poll the deployment
945
1054
  * and infer cross-tenant activity from the global event stream.
946
1055
  */
947
- export function createPollHandler() {
948
- wireLocalEmitters();
1056
+ export function createPollHandler(state = getDefaultAppSyncState()) {
1057
+ // Only the default (in-process) instance wires the local emitters; a gateway
1058
+ // per-app instance learns of changes by tailing its own DB.
1059
+ if (state === getDefaultAppSyncState())
1060
+ state.wireLocalEmitters();
949
1061
  return defineEventHandler(async (event) => {
950
1062
  const session = await getSession(event).catch(() => null);
951
1063
  if (!session?.email) {
952
1064
  setResponseStatus(event, 401);
953
1065
  return { error: "Unauthenticated" };
954
1066
  }
955
- // On cold start, seed _version from DB so we don't return version: 0
956
- await seedVersionFromDb();
957
- const durableEvents = await ensureSyncEventsTable();
1067
+ // On cold start, seed version from DB so we don't return version: 0
1068
+ await state.seedVersionFromDb();
1069
+ const durableEvents = await state.ensureSyncEventsTable();
958
1070
  // Durable sync_events rows are the cheap cross-process path. Keep the
959
1071
  // legacy watermark scan as a slower safety net for direct SQL writes and
960
1072
  // older processes that have not started writing durable events yet.
961
- await checkExternalDbChanges({ durableEvents });
1073
+ await state.checkExternalDbChanges({ durableEvents });
962
1074
  const query = getQuery(event);
963
1075
  const since = parseInt(String(query.since ?? "0"), 10) || 0;
964
- return getCombinedChangesSinceForUser(since, session.email, session.orgId, durableEvents);
1076
+ return state.getCombinedChangesSinceForUser(since, session.email, session.orgId, durableEvents);
965
1077
  });
966
1078
  }
967
1079
  //# sourceMappingURL=poll.js.map