@agent-native/core 0.161.10 → 0.161.12

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 (27) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/export-document.ts +68 -3
  3. package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
  4. package/corpus/templates/content/shared/document-export.ts +25 -0
  5. package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
  6. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
  7. package/corpus/templates/design/app/pages/Index.tsx +45 -1
  8. package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
  9. package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
  10. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
  11. package/dist/action.js +19 -2
  12. package/dist/agent/chat-health-alert.js +10 -1
  13. package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
  14. package/dist/client/embed-auth.js +40 -3
  15. package/dist/client/route-state.js +42 -5
  16. package/dist/client/use-change-version.js +71 -25
  17. package/dist/client/use-db-sync.d.ts +1 -0
  18. package/dist/client/use-db-sync.js +223 -45
  19. package/dist/collab/awareness.d.ts +2 -2
  20. package/dist/collab/awareness.js +37 -4
  21. package/dist/collab/client.js +17 -1
  22. package/dist/collab/struct-routes.d.ts +1 -1
  23. package/dist/notifications/routes.d.ts +1 -1
  24. package/dist/progress/routes.d.ts +1 -1
  25. package/dist/server/poll.d.ts +22 -14
  26. package/dist/server/poll.js +243 -67
  27. package/package.json +1 -1
@@ -23,6 +23,8 @@ import { EventEmitter } from "node:events";
23
23
  import { type DbExec } from "../db/client.js";
24
24
  export interface ChangeEvent {
25
25
  version: number;
26
+ /** Stable durable-row tie-breaker for events sharing a millisecond version. */
27
+ cursorId?: string;
26
28
  source: string;
27
29
  type: string;
28
30
  key?: string;
@@ -53,9 +55,15 @@ export interface ChangeEvent {
53
55
  }
54
56
  export declare const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30000;
55
57
  export declare const POLL_CHANGE_EVENT = "poll-change";
58
+ type SyncCursor = {
59
+ version: number;
60
+ id: string;
61
+ };
56
62
  export type ChangeReadResult = {
57
63
  version: number;
58
64
  events: ChangeEvent[];
65
+ /** Composite `(version,id)` cursor for durable rows sharing a version. */
66
+ cursor?: string;
59
67
  /**
60
68
  * True when the returned version is an intentional cursor stop, not the
61
69
  * source high-water mark. This happens when access is still pending or when a
@@ -143,11 +151,13 @@ export declare class AppSyncState {
143
151
  private dbVersionFallbacks;
144
152
  private warnedListenerThrow;
145
153
  private version;
154
+ private latestCursor;
146
155
  private readonly buffer;
147
156
  private readonly pollEmitter;
148
157
  private syncEventsInitPromise;
149
158
  private lastDurablePrune;
150
159
  private durablePruneFailures;
160
+ private durableWriteFailures;
151
161
  /**
152
162
  * Whether we've seeded `version` from the DB. In serverless (Netlify,
153
163
  * Vercel, etc.) each invocation starts fresh — without seeding, `version`
@@ -175,8 +185,9 @@ export declare class AppSyncState {
175
185
  * after a restart (where an existing row would look like a fresh bump).
176
186
  */
177
187
  private lastScreenRefreshTs;
188
+ private lastScreenRefreshSessionId;
178
189
  private screenRefreshInitialized;
179
- private readonly lastScreenRefreshTsBySession;
190
+ private screenRefreshHasMore;
180
191
  private localEmittersWired;
181
192
  /**
182
193
  * TTL'd access cache for the access-aware branch of `canSeeChangeForUser`,
@@ -232,13 +243,15 @@ export declare class AppSyncState {
232
243
  * `sync_events_created_at_id_idx` drives the batch and the delete is a
233
244
  * bounded index range scan.
234
245
  *
235
- * The `lastDurablePrune` throttle below is per-process, so every serverless
236
- * worker prunes on its first poll. Postgres workers also take a
246
+ * The `lastDurablePrune` throttle below is per-process and starts at process
247
+ * initialization, so a cold serverless worker does not run a potentially
248
+ * large delete during its first request. Postgres workers also take a
237
249
  * transaction-scoped advisory lease for each batch. The lease and delete
238
250
  * are one bounded statement, so a serverless worker killed after the
239
251
  * statement starts cannot leave an open transaction.
240
252
  */
241
253
  private pruneDurableEvents;
254
+ private reportDurableWriteFailure;
242
255
  persistSyncEvent(event: ChangeEvent, dedupeKey?: string, presetId?: string): Promise<void>;
243
256
  /**
244
257
  * Persist with a DB-allocated version (see ALLOCATING_INSERT_SQL). Returns
@@ -340,12 +353,9 @@ export declare class AppSyncState {
340
353
  * Get changes after a given version, filtered to events the caller is
341
354
  * allowed to see.
342
355
  */
343
- getChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined): ChangeReadResult;
344
- getDurableChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined): Promise<ChangeReadResult>;
345
- getCombinedChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined, useDurableEvents: boolean): Promise<{
346
- version: number;
347
- events: ChangeEvent[];
348
- }>;
356
+ getChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined, cursor?: SyncCursor): ChangeReadResult;
357
+ getDurableChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined, cursor?: SyncCursor): Promise<ChangeReadResult>;
358
+ getCombinedChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined, useDurableEvents: boolean, cursor?: SyncCursor): Promise<ChangeReadResult>;
349
359
  /**
350
360
  * Seed `version` from DB timestamps on the first call so serverless cold
351
361
  * starts don't return version 0 and confuse polling clients.
@@ -395,7 +405,7 @@ export declare function getChangesSinceForUser(since: number, userEmail: string,
395
405
  /**
396
406
  * Create an H3 handler for the poll endpoint.
397
407
  *
398
- * GET /_agent-native/poll?since=N → { version, events[] }
408
+ * GET /_agent-native/poll?since=N[&cursor=N.id] → { version, events[] }
399
409
  *
400
410
  * Requires an authenticated session. Events are filtered to the caller's
401
411
  * tenant — global events (owner-less, table-level pings) reach every
@@ -403,9 +413,7 @@ export declare function getChangesSinceForUser(since: number, userEmail: string,
403
413
  * Without auth + filtering, an anonymous attacker could poll the deployment
404
414
  * and infer cross-tenant activity from the global event stream.
405
415
  */
406
- export declare function createPollHandler(state?: AppSyncState): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
407
- version: number;
408
- events: ChangeEvent[];
409
- } | {
416
+ export declare function createPollHandler(state?: AppSyncState): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<ChangeReadResult | {
410
417
  error: string;
411
418
  }>>;
419
+ export {};
@@ -69,6 +69,7 @@ const ACCESS_CACHE_DENY_TTL_MS = 5_000;
69
69
  /** Max cache entries before FIFO eviction kicks in. */
70
70
  const ACCESS_CACHE_MAX = 500;
71
71
  const SCREEN_REFRESH_KEY = "__screen_refresh__";
72
+ const SCREEN_REFRESH_QUERY_LIMIT = 256;
72
73
  /**
73
74
  * DB-assigned version allocation (see `AppSyncStateOptions.dbAssignedVersions`).
74
75
  * The allocator is a one-row table advanced with `GREATEST(v + 1, epoch_ms)` —
@@ -122,6 +123,32 @@ function sqlWatermarkValue(value) {
122
123
  return value;
123
124
  return undefined;
124
125
  }
126
+ function compareSyncCursors(a, b) {
127
+ if (a.version !== b.version)
128
+ return a.version - b.version;
129
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
130
+ }
131
+ function cursorForEvent(event) {
132
+ return { version: event.version, id: event.cursorId ?? "" };
133
+ }
134
+ function encodeSyncCursor(cursor) {
135
+ return `${cursor.version}.${cursor.id}`;
136
+ }
137
+ function decodeSyncCursor(value) {
138
+ if (typeof value !== "string")
139
+ return undefined;
140
+ const separator = value.indexOf(".");
141
+ if (separator < 1)
142
+ return undefined;
143
+ const version = Number(value.slice(0, separator));
144
+ const id = value.slice(separator + 1);
145
+ if (!Number.isSafeInteger(version) || version < 0)
146
+ return undefined;
147
+ return { version, id };
148
+ }
149
+ function isEventAfterCursor(event, cursor) {
150
+ return compareSyncCursors(cursorForEvent(event), cursor) > 0;
151
+ }
125
152
  function syncEventsDisabled() {
126
153
  return (process.env.AGENT_NATIVE_SYNC_EVENTS_DISABLE === "1" ||
127
154
  (process.env.VITEST === "true" &&
@@ -260,11 +287,15 @@ export class AppSyncState {
260
287
  // the same range (seeded from DB, then incremented via Date.now). Plain
261
288
  // ++counter diverges across cold starts.
262
289
  version = 0;
290
+ latestCursor = { version: 0, id: "" };
263
291
  buffer = [];
264
292
  pollEmitter = new EventEmitter();
265
293
  syncEventsInitPromise;
266
- lastDurablePrune = 0;
294
+ // Cold starts should not immediately run a potentially large anti-join. A
295
+ // long-lived process will still prune on its first scheduled interval.
296
+ lastDurablePrune = Date.now();
267
297
  durablePruneFailures = 0;
298
+ durableWriteFailures = 0;
268
299
  /**
269
300
  * Whether we've seeded `version` from the DB. In serverless (Netlify,
270
301
  * Vercel, etc.) each invocation starts fresh — without seeding, `version`
@@ -295,8 +326,9 @@ export class AppSyncState {
295
326
  * after a restart (where an existing row would look like a fresh bump).
296
327
  */
297
328
  lastScreenRefreshTs = 0;
329
+ lastScreenRefreshSessionId = "";
298
330
  screenRefreshInitialized = false;
299
- lastScreenRefreshTsBySession = new Map();
331
+ screenRefreshHasMore = false;
300
332
  localEmittersWired = false;
301
333
  /**
302
334
  * TTL'd access cache for the access-aware branch of `canSeeChangeForUser`,
@@ -460,8 +492,9 @@ export class AppSyncState {
460
492
  * `sync_events_created_at_id_idx` drives the batch and the delete is a
461
493
  * bounded index range scan.
462
494
  *
463
- * The `lastDurablePrune` throttle below is per-process, so every serverless
464
- * worker prunes on its first poll. Postgres workers also take a
495
+ * The `lastDurablePrune` throttle below is per-process and starts at process
496
+ * initialization, so a cold serverless worker does not run a potentially
497
+ * large delete during its first request. Postgres workers also take a
465
498
  * transaction-scoped advisory lease for each batch. The lease and delete
466
499
  * are one bounded statement, so a serverless worker killed after the
467
500
  * statement starts cannot leave an open transaction.
@@ -521,18 +554,23 @@ export class AppSyncState {
521
554
  }
522
555
  }
523
556
  }
557
+ reportDurableWriteFailure(error, event) {
558
+ this.durableWriteFailures++;
559
+ if (this.durableWriteFailures === 1) {
560
+ console.warn(`[agent-native] sync_events write failed for ${event.source}/${event.type}${event.key ? `/${event.key}` : ""}; durable replay is unavailable until the database recovers:`, error instanceof Error ? error.message : String(error));
561
+ }
562
+ }
524
563
  async persistSyncEvent(event, dedupeKey, presetId) {
525
564
  if (!(await this.ensureSyncEventsTable()))
526
565
  return;
527
566
  const client = this.getDb();
528
- await client
529
- .execute({
567
+ await client.execute({
530
568
  sql: this.isPg()
531
569
  ? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
532
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
533
- ON CONFLICT (id) DO NOTHING`
570
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
571
+ ON CONFLICT (id) DO NOTHING`
534
572
  : `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
535
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
536
574
  args: [
537
575
  // presetId lets a gated fallback reuse the allocating attempt's id,
538
576
  // so a commit-then-timeout can't produce the same event twice.
@@ -548,8 +586,8 @@ export class AppSyncState {
548
586
  event.resourceId ?? null,
549
587
  Date.now(),
550
588
  ],
551
- })
552
- .catch(() => { });
589
+ });
590
+ this.durableWriteFailures = 0;
553
591
  await this.pruneDurableEvents(client);
554
592
  }
555
593
  /**
@@ -797,17 +835,27 @@ export class AppSyncState {
797
835
  // drop every later event.
798
836
  this.recordChain = this.recordChain
799
837
  .then(() => this.recordWithDbVersion(event, opts?.dedupeKey))
800
- .catch(() => { });
838
+ .catch((error) => {
839
+ this.reportDurableWriteFailure(error, event);
840
+ });
801
841
  return;
802
842
  }
803
843
  this.version = Math.max(this.version + 1, Date.now());
804
- const entry = { ...event, version: this.version };
844
+ const provisional = { ...event, version: this.version };
845
+ const cursorId = this.durableEventId(provisional, opts?.dedupeKey);
846
+ const entry = { ...provisional, cursorId };
805
847
  this.commitEntry(entry);
806
- void this.persistSyncEvent(entry, opts?.dedupeKey);
848
+ void this.persistSyncEvent(entry, opts?.dedupeKey, cursorId).catch((error) => {
849
+ this.reportDurableWriteFailure(error, entry);
850
+ });
807
851
  }
808
852
  /** Buffer + emit. Shared by both version-allocation modes. */
809
853
  commitEntry(entry) {
810
854
  this.buffer.push(entry);
855
+ const cursor = cursorForEvent(entry);
856
+ if (compareSyncCursors(cursor, this.latestCursor) > 0) {
857
+ this.latestCursor = cursor;
858
+ }
811
859
  if (this.buffer.length > MAX_BUFFER) {
812
860
  this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
813
861
  }
@@ -826,7 +874,7 @@ export class AppSyncState {
826
874
  : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
827
875
  let version = null;
828
876
  try {
829
- version = await this.persistWithDbAssignedVersion(event, id);
877
+ version = await this.persistWithDbAssignedVersion({ ...event, cursorId: id }, id);
830
878
  }
831
879
  catch {
832
880
  version = null;
@@ -851,10 +899,16 @@ export class AppSyncState {
851
899
  console.warn("[agent-native] sync version allocation failed; falling back to clock-assigned versions");
852
900
  }
853
901
  this.version = Math.max(this.version + 1, Date.now());
854
- const entry = { ...event, version: this.version };
902
+ const entry = {
903
+ ...event,
904
+ version: this.version,
905
+ cursorId: id,
906
+ };
855
907
  await this.alignVersionAllocator(this.version);
856
908
  this.commitEntryForChain(entry);
857
- void this.persistSyncEvent(entry, dedupeKey, id);
909
+ void this.persistSyncEvent(entry, dedupeKey, id).catch((error) => {
910
+ this.reportDurableWriteFailure(error, entry);
911
+ });
858
912
  return;
859
913
  }
860
914
  // A deterministic-id dedupe loser adopts the winner's (possibly older)
@@ -862,7 +916,7 @@ export class AppSyncState {
862
916
  // delivered correctly and the version-keyed combined-read dedupe collapses
863
917
  // it against the durable row.
864
918
  this.version = Math.max(this.version, version);
865
- this.commitEntryForChain({ ...event, version });
919
+ this.commitEntryForChain({ ...event, version, cursorId: id });
866
920
  }
867
921
  /**
868
922
  * `commitEntry` for the deferred (chained) path: the buffer push must land
@@ -929,32 +983,61 @@ export class AppSyncState {
929
983
  * Get changes after a given version, filtered to events the caller is
930
984
  * allowed to see.
931
985
  */
932
- getChangesSinceForUser(since, userEmail, orgId) {
933
- if (since >= this.version) {
934
- return { version: this.version, events: [] };
986
+ getChangesSinceForUser(since, userEmail, orgId, cursor) {
987
+ const readCursor = cursor ?? { version: since, id: "" };
988
+ if ((!cursor && since >= this.version) ||
989
+ (cursor && compareSyncCursors(this.latestCursor, readCursor) <= 0)) {
990
+ return {
991
+ version: this.version,
992
+ events: [],
993
+ ...(cursor ? { cursor: encodeSyncCursor(readCursor) } : {}),
994
+ };
935
995
  }
936
996
  const events = [];
937
997
  let version = this.version;
998
+ let lastCursor = readCursor;
938
999
  for (const event of this.buffer) {
939
- if (event.version <= since)
1000
+ if (cursor ? !isEventAfterCursor(event, readCursor) : event.version <= since) {
940
1001
  continue;
1002
+ }
941
1003
  const visibility = this.getChangeVisibilityForUser(event, userEmail, orgId);
942
1004
  if (visibility === "visible") {
943
1005
  events.push(event);
1006
+ lastCursor = cursorForEvent(event);
944
1007
  continue;
945
1008
  }
946
1009
  if (visibility === "pending") {
947
1010
  version = Math.max(since, event.version - 1);
948
- return { version, events, cursorLimited: true };
1011
+ return {
1012
+ version,
1013
+ events,
1014
+ cursorLimited: true,
1015
+ ...(cursor && lastCursor !== readCursor
1016
+ ? { cursor: encodeSyncCursor(lastCursor) }
1017
+ : {}),
1018
+ };
949
1019
  }
1020
+ lastCursor = cursorForEvent(event);
950
1021
  }
951
- return { version, events };
1022
+ return {
1023
+ version,
1024
+ events,
1025
+ ...(cursor && compareSyncCursors(lastCursor, readCursor) > 0
1026
+ ? { cursor: encodeSyncCursor(lastCursor) }
1027
+ : {}),
1028
+ };
952
1029
  }
953
- async getDurableChangesSinceForUser(since, userEmail, orgId) {
954
- if (since <= 0 || !(await this.ensureSyncEventsTable())) {
1030
+ async getDurableChangesSinceForUser(since, userEmail, orgId, cursor) {
1031
+ if ((!cursor && since <= 0) ||
1032
+ (cursor && cursor.version <= 0 && cursor.id === "") ||
1033
+ !(await this.ensureSyncEventsTable())) {
955
1034
  return { version: this.version, events: [] };
956
1035
  }
957
1036
  try {
1037
+ const readCursor = cursor ?? { version: since, id: "" };
1038
+ const compositeCursorSql = cursor
1039
+ ? "(version > ? OR (version = ? AND id > ?))"
1040
+ : "version > ?";
958
1041
  // Scope the fetch to rows that could ever be visible to this caller
959
1042
  // before paying to JSON.parse and visibility-check every deployment-wide
960
1043
  // event: deployment-global rows (no owner, no org), the caller's own
@@ -965,19 +1048,29 @@ export class AppSyncState {
965
1048
  // `orgId` bind param, which makes `org_id = ?` match no row in both
966
1049
  // dialects — mirroring the `event.orgId && orgId` truthy check.
967
1050
  const result = await this.getDb().execute({
968
- sql: `SELECT version, event_json FROM sync_events WHERE version > ?
1051
+ sql: `SELECT id, version, event_json FROM sync_events WHERE ${compositeCursorSql}
969
1052
  AND (
970
1053
  (owner IS NULL AND org_id IS NULL)
971
1054
  OR owner = ?
972
1055
  OR org_id = ?
973
1056
  OR resource_type IS NOT NULL
974
1057
  )
975
- ORDER BY version ASC LIMIT ?`,
976
- args: [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
1058
+ ORDER BY version ASC, id ASC LIMIT ?`,
1059
+ args: cursor
1060
+ ? [
1061
+ cursor.version,
1062
+ cursor.version,
1063
+ cursor.id,
1064
+ userEmail,
1065
+ orgId ?? null,
1066
+ DURABLE_READ_LIMIT + 1,
1067
+ ]
1068
+ : [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
977
1069
  });
978
1070
  const events = [];
979
1071
  let version = Math.max(this.version, since);
980
1072
  let lastDurableVersion = since;
1073
+ let lastCursor = readCursor;
981
1074
  const rows = result.rows.slice(0, DURABLE_READ_LIMIT);
982
1075
  const overflowVersion = timestampValue(result.rows[DURABLE_READ_LIMIT]?.version);
983
1076
  for (const row of rows) {
@@ -986,6 +1079,8 @@ export class AppSyncState {
986
1079
  lastDurableVersion = rawVersion;
987
1080
  if (rawVersion > version)
988
1081
  version = rawVersion;
1082
+ const rowId = typeof row.id === "string" ? row.id : "";
1083
+ let rowCursor = rowId ? { version: rawVersion, id: rowId } : undefined;
989
1084
  let event = null;
990
1085
  try {
991
1086
  const parsed = JSON.parse(String(row.event_json));
@@ -996,17 +1091,33 @@ export class AppSyncState {
996
1091
  event = {
997
1092
  ...parsed,
998
1093
  version: rawVersion || parsed.version,
1094
+ ...(rowId || typeof parsed.cursorId === "string"
1095
+ ? {
1096
+ cursorId: rowId || parsed.cursorId,
1097
+ }
1098
+ : {}),
999
1099
  };
1000
1100
  }
1001
1101
  }
1002
1102
  catch {
1003
1103
  event = null;
1004
1104
  }
1005
- if (!event)
1105
+ if (!event) {
1106
+ // Malformed durable rows are skipped from delivery, but their
1107
+ // composite cursor still needs to advance. Otherwise every poll
1108
+ // rereads the same bad row forever and can never reach later rows.
1109
+ if (rowCursor)
1110
+ lastCursor = rowCursor;
1006
1111
  continue;
1112
+ }
1113
+ if (!rowCursor && typeof event.cursorId === "string") {
1114
+ rowCursor = { version: event.version, id: event.cursorId };
1115
+ }
1007
1116
  const visibility = this.getChangeVisibilityForUser(event, userEmail, orgId);
1008
1117
  if (visibility === "visible") {
1009
1118
  events.push(event);
1119
+ if (rowCursor)
1120
+ lastCursor = rowCursor;
1010
1121
  continue;
1011
1122
  }
1012
1123
  if (visibility === "pending") {
@@ -1014,10 +1125,23 @@ export class AppSyncState {
1014
1125
  version: Math.max(since, event.version - 1),
1015
1126
  events,
1016
1127
  cursorLimited: true,
1128
+ ...(cursor && compareSyncCursors(lastCursor, readCursor) > 0
1129
+ ? { cursor: encodeSyncCursor(lastCursor) }
1130
+ : {}),
1017
1131
  };
1018
1132
  }
1133
+ if (rowCursor)
1134
+ lastCursor = rowCursor;
1019
1135
  }
1020
1136
  if (rows.length >= DURABLE_READ_LIMIT) {
1137
+ if (cursor) {
1138
+ return {
1139
+ version: Math.max(since, lastDurableVersion),
1140
+ events,
1141
+ cursor: encodeSyncCursor(lastCursor),
1142
+ cursorLimited: result.rows.length > DURABLE_READ_LIMIT,
1143
+ };
1144
+ }
1021
1145
  if (overflowVersion === lastDurableVersion) {
1022
1146
  const boundaryVersion = lastDurableVersion;
1023
1147
  return {
@@ -1032,20 +1156,31 @@ export class AppSyncState {
1032
1156
  cursorLimited: true,
1033
1157
  };
1034
1158
  }
1035
- return { version, events };
1159
+ return {
1160
+ version,
1161
+ events,
1162
+ ...(cursor && compareSyncCursors(lastCursor, readCursor) > 0
1163
+ ? { cursor: encodeSyncCursor(lastCursor) }
1164
+ : {}),
1165
+ };
1036
1166
  }
1037
1167
  catch {
1038
- return { version: this.version, events: [] };
1168
+ return {
1169
+ version: this.version,
1170
+ events: [],
1171
+ ...(cursor ? { cursor: encodeSyncCursor(cursor) } : {}),
1172
+ };
1039
1173
  }
1040
1174
  }
1041
- async getCombinedChangesSinceForUser(since, userEmail, orgId, useDurableEvents) {
1042
- const memory = this.getChangesSinceForUser(since, userEmail, orgId);
1175
+ async getCombinedChangesSinceForUser(since, userEmail, orgId, useDurableEvents, cursor) {
1176
+ const memory = this.getChangesSinceForUser(since, userEmail, orgId, cursor);
1043
1177
  if (!useDurableEvents)
1044
1178
  return memory;
1045
- const durable = await this.getDurableChangesSinceForUser(since, userEmail, orgId);
1179
+ const durable = await this.getDurableChangesSinceForUser(since, userEmail, orgId, cursor);
1046
1180
  const byIdentity = new Map();
1047
1181
  for (const event of [...durable.events, ...memory.events]) {
1048
1182
  byIdentity.set(JSON.stringify([
1183
+ event.cursorId,
1049
1184
  event.version,
1050
1185
  event.source,
1051
1186
  event.type,
@@ -1056,7 +1191,37 @@ export class AppSyncState {
1056
1191
  event.resourceId,
1057
1192
  ]), event);
1058
1193
  }
1059
- const events = Array.from(byIdentity.values()).sort((a, b) => a.version - b.version);
1194
+ const events = Array.from(byIdentity.values()).sort((a, b) => compareSyncCursors(cursorForEvent(a), cursorForEvent(b)));
1195
+ if (cursor) {
1196
+ const limitedCursors = [memory, durable]
1197
+ .filter((result) => result.cursorLimited)
1198
+ .map((result) => decodeSyncCursor(result.cursor))
1199
+ .filter((value) => !!value);
1200
+ if (limitedCursors.length > 0) {
1201
+ const boundary = limitedCursors.reduce((minimum, value) => compareSyncCursors(value, minimum) < 0 ? value : minimum);
1202
+ return {
1203
+ version: boundary.version,
1204
+ cursor: encodeSyncCursor(boundary),
1205
+ cursorLimited: true,
1206
+ events: events.filter((event) => compareSyncCursors(cursorForEvent(event), boundary) <= 0),
1207
+ };
1208
+ }
1209
+ const responseCursor = [
1210
+ cursor,
1211
+ decodeSyncCursor(memory.cursor),
1212
+ decodeSyncCursor(durable.cursor),
1213
+ ...events.map(cursorForEvent),
1214
+ ]
1215
+ .filter((value) => !!value)
1216
+ .reduce((maximum, value) => compareSyncCursors(value, maximum) > 0 ? value : maximum, cursor);
1217
+ return {
1218
+ version: Math.max(memory.version, durable.version, since),
1219
+ events,
1220
+ ...(compareSyncCursors(responseCursor, cursor) > 0
1221
+ ? { cursor: encodeSyncCursor(responseCursor) }
1222
+ : {}),
1223
+ };
1224
+ }
1060
1225
  const limitedVersions = [memory, durable]
1061
1226
  .filter((result) => result.cursorLimited)
1062
1227
  .map((result) => result.version);
@@ -1088,7 +1253,7 @@ export class AppSyncState {
1088
1253
  readActionMarkerMaxUpdatedAt(db),
1089
1254
  db
1090
1255
  .execute({
1091
- sql: "SELECT session_id, updated_at FROM application_state WHERE key = ?",
1256
+ sql: "SELECT session_id, updated_at FROM application_state WHERE key = ? ORDER BY updated_at DESC, session_id DESC LIMIT 1",
1092
1257
  args: [SCREEN_REFRESH_KEY],
1093
1258
  })
1094
1259
  .catch(() => ({ rows: [] })),
@@ -1130,12 +1295,11 @@ export class AppSyncState {
1130
1295
  this.lastActionMarkerTs = Math.max(0, actionMarkerTs - ACTION_MARKER_REPLAY_WINDOW_MS);
1131
1296
  this.replayActionMarkerOnce = actionMarkerTs > 0;
1132
1297
  this.lastScreenRefreshTs = refreshTs;
1133
- this.lastScreenRefreshTsBySession.clear();
1134
- for (const row of refreshResult.rows) {
1135
- if (typeof row.session_id === "string") {
1136
- this.lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
1137
- }
1138
- }
1298
+ this.lastScreenRefreshSessionId =
1299
+ typeof refreshResult.rows[0]?.session_id === "string"
1300
+ ? refreshResult.rows[0].session_id
1301
+ : "";
1302
+ this.screenRefreshHasMore = false;
1139
1303
  this.screenRefreshInitialized = true;
1140
1304
  // Skip the redundant cold-start recheck unless there is an existing durable
1141
1305
  // action marker that the first poll still needs to emit.
@@ -1207,15 +1371,28 @@ export class AppSyncState {
1207
1371
  const replayActionMarker = this.replayActionMarkerOnce;
1208
1372
  this.replayActionMarkerOnce = false;
1209
1373
  const appStateChanged = appStateMaxTs > this.lastAppStateTs || replayActionMarker;
1210
- const [appResult, refreshResult, extensionMarkerTs] = appStateChanged
1374
+ const screenRefreshNeedsCheck = appStateChanged || this.screenRefreshHasMore;
1375
+ const [appResult, refreshResult, extensionMarkerTs] = screenRefreshNeedsCheck
1211
1376
  ? await Promise.all([
1377
+ appStateChanged
1378
+ ? db.execute({
1379
+ sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
1380
+ args: [this.lastAppStateTs],
1381
+ })
1382
+ : Promise.resolve({ rows: [] }),
1212
1383
  db.execute({
1213
- sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
1214
- args: [this.lastAppStateTs],
1215
- }),
1216
- db.execute({
1217
- sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
1218
- args: [SCREEN_REFRESH_KEY],
1384
+ sql: `SELECT session_id, updated_at, value FROM application_state
1385
+ WHERE key = ?
1386
+ AND (updated_at > ? OR (updated_at = ? AND session_id > ?))
1387
+ ORDER BY updated_at ASC, session_id ASC
1388
+ LIMIT ?`,
1389
+ args: [
1390
+ SCREEN_REFRESH_KEY,
1391
+ this.lastScreenRefreshTs,
1392
+ this.lastScreenRefreshTs,
1393
+ this.lastScreenRefreshSessionId,
1394
+ SCREEN_REFRESH_QUERY_LIMIT,
1395
+ ],
1219
1396
  }),
1220
1397
  readExtensionMarkerMaxUpdatedAt(db),
1221
1398
  ])
@@ -1282,17 +1459,16 @@ export class AppSyncState {
1282
1459
  // application_state row moved, which is also exactly when this block
1283
1460
  // could not emit anything.
1284
1461
  if (refreshResult) {
1285
- const refreshTs = refreshResult.rows.reduce((max, row) => Math.max(max, timestampValue(row.updated_at)), 0);
1286
1462
  if (!this.screenRefreshInitialized) {
1287
- this.lastScreenRefreshTs = refreshTs;
1288
- for (const row of refreshResult.rows) {
1289
- if (typeof row.session_id === "string") {
1290
- this.lastScreenRefreshTsBySession.set(row.session_id, timestampValue(row.updated_at));
1291
- }
1292
- }
1463
+ const lastRow = refreshResult.rows.at(-1);
1464
+ this.lastScreenRefreshTs = timestampValue(lastRow?.updated_at);
1465
+ this.lastScreenRefreshSessionId =
1466
+ typeof lastRow?.session_id === "string" ? lastRow.session_id : "";
1467
+ this.screenRefreshHasMore =
1468
+ refreshResult.rows.length >= SCREEN_REFRESH_QUERY_LIMIT;
1293
1469
  this.screenRefreshInitialized = true;
1294
1470
  }
1295
- else if (refreshTs > this.lastScreenRefreshTs) {
1471
+ else {
1296
1472
  // Emit a per-user event only for the session(s) whose row actually
1297
1473
  // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
1298
1474
  // to that user — not every authenticated poller.
@@ -1301,9 +1477,6 @@ export class AppSyncState {
1301
1477
  if (!owner)
1302
1478
  continue;
1303
1479
  const rowTs = timestampValue(row.updated_at);
1304
- if (rowTs <= (this.lastScreenRefreshTsBySession.get(owner) ?? 0)) {
1305
- continue;
1306
- }
1307
1480
  let scope;
1308
1481
  try {
1309
1482
  const raw = row.value;
@@ -1320,10 +1493,12 @@ export class AppSyncState {
1320
1493
  key: SCREEN_REFRESH_KEY,
1321
1494
  owner,
1322
1495
  ...(scope ? { scope } : {}),
1323
- }, { dedupeKey: `screen-refresh|${rowTs}` });
1324
- this.lastScreenRefreshTsBySession.set(owner, rowTs);
1496
+ }, { dedupeKey: `screen-refresh|${rowTs}|${owner}` });
1497
+ this.lastScreenRefreshTs = rowTs;
1498
+ this.lastScreenRefreshSessionId = owner;
1325
1499
  }
1326
- this.lastScreenRefreshTs = refreshTs;
1500
+ this.screenRefreshHasMore =
1501
+ refreshResult.rows.length >= SCREEN_REFRESH_QUERY_LIMIT;
1327
1502
  }
1328
1503
  }
1329
1504
  // Extension mutations write a durable marker row so delete and hide/unhide
@@ -1452,7 +1627,7 @@ export function getChangesSinceForUser(since, userEmail, orgId) {
1452
1627
  /**
1453
1628
  * Create an H3 handler for the poll endpoint.
1454
1629
  *
1455
- * GET /_agent-native/poll?since=N → { version, events[] }
1630
+ * GET /_agent-native/poll?since=N[&cursor=N.id] → { version, events[] }
1456
1631
  *
1457
1632
  * Requires an authenticated session. Events are filtered to the caller's
1458
1633
  * tenant — global events (owner-less, table-level pings) reach every
@@ -1479,7 +1654,8 @@ export function createPollHandler(state = getDefaultAppSyncState()) {
1479
1654
  // older processes that have not started writing durable events yet.
1480
1655
  await state.checkExternalDbChanges({ durableEvents });
1481
1656
  const query = getQuery(event);
1482
- const since = parseInt(String(query.since ?? "0"), 10) || 0;
1483
- return state.getCombinedChangesSinceForUser(since, session.email, session.orgId, durableEvents);
1657
+ const cursor = decodeSyncCursor(query.cursor);
1658
+ const since = cursor?.version ?? (parseInt(String(query.since ?? "0"), 10) || 0);
1659
+ return state.getCombinedChangesSinceForUser(since, session.email, session.orgId, durableEvents, cursor);
1484
1660
  });
1485
1661
  }