@gmickel/gno 1.43.0 → 1.44.0

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.
@@ -0,0 +1 @@
1
+ 31a5cf0632f64360093ef6184708a77244d0e65e6d344d3ba6e11902fcab12b1 gno-browser-clipper-v1.44.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.43.0"
24
+ "version": "1.44.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
package/spec/cli.md CHANGED
@@ -1025,24 +1025,51 @@ gno index [collection] [--no-embed] [--models-pull] [--git-pull] [--json] [--yes
1025
1025
 
1026
1026
  **Behavior:**
1027
1027
 
1028
- - Runs `update` then `embed` by default
1029
- - With `--no-embed`, runs `update` only
1028
+ - Runs two separable stages: `lexical` (`update`) then `embed`
1029
+ - With `--no-embed`, runs the `lexical` stage only (embed stage `skipped`)
1030
1030
  - Waits by default for the shared write lease; `--no-wait` fails immediately with exit 4
1031
1031
 
1032
- **JSON output:** Emits `{ syncResult, embedSkipped, embedResult? }`.
1032
+ **Staged, resumable contract:** each stage persists its own progress inside
1033
+ the index database (documents and chunks for `lexical`; vectors per batch for
1034
+ `embed`) plus a per-stage lifecycle marker (`schema_meta.index_stage_state`).
1035
+ A stage that fails never invalidates a completed earlier stage: the lexical
1036
+ index stays searchable when embedding fails or the process dies. A process
1037
+ killed mid-stage (SIGKILL, native crash, power loss) emits nothing; the next
1038
+ `gno index` or `gno embed` run reads the marker, reports the interrupted stage
1039
+ in its resume preamble (stderr in human mode, `resumedFrom` in JSON), and
1040
+ continues from persisted progress - unchanged files are skipped and already
1041
+ stored chunks are never re-embedded. A `gno index --no-embed` run that
1042
+ surfaces an interrupted embed stage settles that marker (the embed progress
1043
+ itself stays on disk), so later runs do not repeat the preamble.
1044
+
1045
+ **JSON output:** conforms to
1046
+ [`index-receipt@1.0`](./output-schemas/index-receipt.schema.json):
1047
+ `{ success, error?, stages: { lexical, embed }, resumedFrom, syncResult?, embedSkipped, embedResult? }`.
1048
+ Each stage reports `state` (`completed` | `failed` | `skipped` |
1049
+ `interrupted`) with counts (`filesProcessed`/`filesAdded`/`filesUpdated`/
1050
+ `filesErrored`/`filesSkipped`/`durationMs` for lexical;
1051
+ `embedded`/`errors`/`contentionErrors`/`durationMs` for embed), a `reason`
1052
+ when skipped, and an `error` when failed. The embed stage is `completed` only
1053
+ when every attempted chunk was stored: any `errors > 0` or a vector-sync error
1054
+ makes it `failed`. `resumedFrom` is `null` on a clean start or
1055
+ `{ stage, state: "interrupted", startedAt, pid, collection? }`.
1033
1056
  `syncResult.collections[].files[].recordImport`, when present, conforms to
1034
1057
  [`record-import@1.0`](./output-schemas/record-import.schema.json) with the same
1035
1058
  deterministic ordering, bounds, truncation disclosure, and partial-snapshot
1036
1059
  warnings as `gno update --json`. Human progress and diagnostics remain on
1037
- stderr. On lease timeout, stdout is one object `{ success: false, error, contention }`
1038
- and the process exits 4.
1060
+ stderr. A failed stage still emits the partial receipt on stdout (with
1061
+ `success: false` and `error`) before the non-zero exit. On lease timeout,
1062
+ stdout is one object `{ success: false, error, contention }` and the process
1063
+ exits 4.
1039
1064
 
1040
1065
  **Exit Codes:**
1041
1066
 
1042
- - 0: Success
1067
+ - 0: Every attempted stage completed
1043
1068
  - 1: Invalid collection name or invalid `--lock-wait`
1044
- - 2: DB or model failure
1045
- - 4: Write lease busy (contention, not corruption)
1069
+ - 2: DB or model failure, or any stage `failed` (including chunk-level embed
1070
+ failures; the partial receipt is still emitted)
1071
+ - 4: Write lease busy (contention, not corruption), or chunks deferred by
1072
+ index contention after an otherwise completed embed stage
1046
1073
 
1047
1074
  **Examples:**
1048
1075
 
@@ -2372,11 +2399,21 @@ gno doctor [--json|--md]
2372
2399
  }
2373
2400
  ]
2374
2401
  }
2402
+ },
2403
+ {
2404
+ "name": "findings-pass",
2405
+ "status": "ok",
2406
+ "message": "disabled (opt-in via findings.enabled)"
2375
2407
  }
2376
2408
  ]
2377
2409
  }
2378
2410
  ```
2379
2411
 
2412
+ The `findings-pass` check reports the daemon's scheduled findings pass from
2413
+ its persisted run state: `ok` when disabled or the last run succeeded, `warn`
2414
+ on `skipped_lease` / `overdue` / no recorded run, `error` on `failed` or a
2415
+ misconfigured `findings` block (see [Daemon Mode](../docs/DAEMON.md#scheduled-findings-pass)).
2416
+
2380
2417
  The `embedding-fingerprint` check is additive doctor-only diagnostics. It uses
2381
2418
  the active embed model and stored vector dimensions to report the current
2382
2419
  freshness fingerprint, pending/stale chunks, legacy empty-fingerprint vectors,
@@ -3595,6 +3632,7 @@ List retained, metadata-only document lifecycle changes.
3595
3632
 
3596
3633
  ```bash
3597
3634
  gno changes [--since <ISO-8601|cursor>] [--collection <name>] [--limit <n>] [--json]
3635
+ gno changes --follow --jsonl [--cursor <cursor>] [--collection <name>]
3598
3636
  ```
3599
3637
 
3600
3638
  - `--since` accepts an ISO-8601 time or an opaque cursor returned by an earlier
@@ -3605,6 +3643,42 @@ gno changes [--since <ISO-8601|cursor>] [--collection <name>] [--limit <n>] [--j
3605
3643
  cursor-expiry, and retention-truncation disclosure.
3606
3644
  - The journal never returns source bodies.
3607
3645
 
3646
+ **Follow mode (`--follow --jsonl`)** streams journal events as they land and is
3647
+ the durable automation input for consumers that resume across restarts.
3648
+
3649
+ - `--follow` and `--jsonl` are one mode and must be given together; `--cursor`
3650
+ requires both. The mode excludes `--since`, `--limit`, and `--json` (exit 1).
3651
+ `--collection` filters the stream.
3652
+ - Wire contract: one JSON object per stdout line, validated by
3653
+ `changes-follow-event.schema.json`. An event line is
3654
+ `{"event": <change>, "postCursor": "<cursor>"}` where `event` is one
3655
+ `changes.schema.json` change and `postCursor` is the journal cursor after
3656
+ that event was applied. Lines are emitted in journal order.
3657
+ - Checkpoint rule: a consumer persists `postCursor` after it has durably
3658
+ handled the line and restarts with `--cursor <postCursor>`; nothing at or
3659
+ before that cursor is replayed. Delivery is at-least-once: a line the
3660
+ consumer received but did not checkpoint is redelivered on resume, so
3661
+ handlers must be idempotent by `event.id` (each event id is unique and
3662
+ equals its own `postCursor`).
3663
+ - Start position: without `--cursor` the stream starts at the journal's
3664
+ current `latestCursor` (tail semantics, no backfill). `--cursor` must be an
3665
+ opaque cursor from an earlier response (exit 1 when malformed or ahead of
3666
+ the journal).
3667
+ - Quiet periods emit nothing. There is no keepalive line in v1; consumers
3668
+ detect liveness from the process, not the stream.
3669
+ - Cursor expiry: when the resume cursor falls below the retention floor the
3670
+ stream writes exactly one terminal line,
3671
+ `{"error": "cursor_expired", "earliestCursor": "<cursor>", "latestCursor": "<cursor>"}`,
3672
+ then exits 2 with nothing on stderr. `earliestCursor` is the journal's
3673
+ documented resume floor (`gno changes --json` reports the same value);
3674
+ `latestCursor` is the current tail. The consumer chooses whether to backfill
3675
+ from `earliestCursor` or resume from `latestCursor` and accept the gap;
3676
+ resuming from `latestCursor` skips every retained event.
3677
+ - Signals: SIGINT or SIGTERM ends the stream after the line in progress and
3678
+ exits 0. No partial line is ever written.
3679
+ - The reader never takes the write lease; `gno index`, `gno update`, capture,
3680
+ and the daemon are never blocked by a follower.
3681
+
3608
3682
  ### gno diff
3609
3683
 
3610
3684
  Show the latest retained structural delta for one document, or select an exact
@@ -3851,6 +3925,13 @@ is blocked.
3851
3925
  watcher behavior.
3852
3926
  - Runs an initial sync by default
3853
3927
  - Triggers embedding after initial sync completes
3928
+ - With `findings.enabled`, runs the scheduled findings pass on its cadence:
3929
+ a read-only audit of every collection except the findings one, written as
3930
+ deterministic Markdown records into `findings.collection`. The audit runs
3931
+ without the write lease; only the record write takes it (no wait; a busy
3932
+ lease is recorded as `skipped_lease`). Every attempt persists to
3933
+ `{data}/index-<name>.findings-run.json`, surfaced by `--status` (`findings`)
3934
+ and the `findings-pass` doctor check. See [Daemon Mode](../docs/DAEMON.md#scheduled-findings-pass)
3854
3935
  - Runs in the foreground until `SIGINT` / `SIGTERM`
3855
3936
  - Starts a headless `/mcp` Streamable HTTP listener; it does not serve the Web UI
3856
3937
  - Exposes the same safe REST lifecycle snapshot at `/api/resident/status`;
package/spec/mcp.md CHANGED
@@ -1537,6 +1537,17 @@ Create a new document in a collection (write-enabled).
1537
1537
  - For non-Markdown files, tags are stored as user-source in the database
1538
1538
  - Receipts distinguish write result from sync and embedding state; capture does
1539
1539
  not imply embedding unless `embed.status` is `completed`
1540
+ - Success is retrievability: the write and its lexical sync complete under the
1541
+ shared write lease before the tool returns, so a successful result always
1542
+ carries `sync.status: "completed"` and the note is an immediate `gno_search`
1543
+ hit. A written file whose sync fails is a tool error `CAPTURE_SYNC_FAILED`
1544
+ (message names the written path; run `gno update` to retry), never a success
1545
+ with `sync.status: "failed"`
1546
+ - `open_existing` on a file that is on disk but not indexed yet syncs it before
1547
+ returning `collisionPolicyResult: "opened_existing"`
1548
+ - The lease wait follows the v1.38 contention contract: the tool waits up to
1549
+ 120s for a concurrent CLI/MCP/REST writer, then returns `LOCKED` without
1550
+ writing
1540
1551
  - Writes run under the MCP write lock and are only registered when the server
1541
1552
  starts with `--enable-write` or `GNO_MCP_ENABLE_WRITE=1`
1542
1553
 
@@ -2742,6 +2753,7 @@ Resource errors use standard MCP error responses.
2742
2753
  - `PATH_NOT_FOUND` — Path does not exist
2743
2754
  - `JOB_CONFLICT` — Another job is already running
2744
2755
  - `LOCKED` — Another MCP process holds the write lock
2756
+ - `CAPTURE_SYNC_FAILED` — `gno_capture` wrote the file but lexical sync failed
2745
2757
  - `WRITE_DISABLED` — Write tool dispatched while writes are disabled
2746
2758
  - `MEMORY_*` — Memory contract errors from `gno_recall` / `gno_remember`;
2747
2759
  the stable code set is `MemoryErrorCode` in `src/core/memory.ts` and each
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "gno://schemas/changes-follow-event@1.0",
4
+ "title": "GNO Knowledge Changes Follow Line",
5
+ "description": "One stdout line of `gno changes --follow --jsonl`: either a journal event with the cursor after it was applied, or the single terminal cursor-expiry record.",
6
+ "oneOf": [
7
+ { "$ref": "#/definitions/eventLine" },
8
+ { "$ref": "#/definitions/expiredLine" }
9
+ ],
10
+ "definitions": {
11
+ "eventLine": {
12
+ "type": "object",
13
+ "required": ["event", "postCursor"],
14
+ "properties": {
15
+ "event": { "$ref": "gno://schemas/changes@1.0#/definitions/change" },
16
+ "postCursor": { "type": "string", "minLength": 1, "maxLength": 512 }
17
+ },
18
+ "additionalProperties": false
19
+ },
20
+ "expiredLine": {
21
+ "type": "object",
22
+ "required": ["error", "earliestCursor", "latestCursor"],
23
+ "properties": {
24
+ "error": { "type": "string", "const": "cursor_expired" },
25
+ "earliestCursor": {
26
+ "type": "string",
27
+ "minLength": 1,
28
+ "maxLength": 512
29
+ },
30
+ "latestCursor": { "type": "string", "minLength": 1, "maxLength": 512 }
31
+ },
32
+ "additionalProperties": false
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,135 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "gno://schemas/index-receipt@1.0",
4
+ "title": "GNO Index Receipt",
5
+ "description": "Per-stage receipt emitted by `gno index --json`. The lexical (sync) and embed stages are separable and resumable; the run succeeds only when every attempted stage completed. A run interrupted by a killed process emits nothing - the next run reports the interrupted stage under `resumedFrom`.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["success", "stages", "resumedFrom", "embedSkipped"],
9
+ "properties": {
10
+ "success": {
11
+ "type": "boolean",
12
+ "description": "True only when every attempted stage completed."
13
+ },
14
+ "error": {
15
+ "type": "string",
16
+ "description": "Present when success is false: the failed stage's error."
17
+ },
18
+ "stages": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["lexical", "embed"],
22
+ "properties": {
23
+ "lexical": { "$ref": "#/definitions/lexicalStage" },
24
+ "embed": { "$ref": "#/definitions/embedStage" }
25
+ }
26
+ },
27
+ "resumedFrom": {
28
+ "oneOf": [
29
+ { "$ref": "#/definitions/interruptedStage" },
30
+ { "type": "null" }
31
+ ],
32
+ "description": "Stage a previous run left running (process died mid-stage); null when this run started clean."
33
+ },
34
+ "syncResult": {
35
+ "type": "object",
36
+ "description": "Complete deterministic sync result (same shape as `gno update --json`); absent when the lexical stage failed before producing one."
37
+ },
38
+ "embedSkipped": {
39
+ "type": "boolean",
40
+ "description": "True when `--no-embed` was passed."
41
+ },
42
+ "embedResult": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["embedded", "errors", "contentionErrors", "duration"],
46
+ "description": "Embedding outcome when the embed stage ran to a result (present for completed and for partially failed passes; absent when the stage failed before embedding).",
47
+ "properties": {
48
+ "embedded": { "type": "integer", "minimum": 0 },
49
+ "errors": { "type": "integer", "minimum": 0 },
50
+ "contentionErrors": { "type": "integer", "minimum": 0 },
51
+ "duration": {
52
+ "type": "number",
53
+ "minimum": 0,
54
+ "description": "Seconds."
55
+ }
56
+ }
57
+ }
58
+ },
59
+ "definitions": {
60
+ "stageState": {
61
+ "type": "string",
62
+ "enum": ["completed", "failed", "skipped", "interrupted"]
63
+ },
64
+ "lexicalStage": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "required": [
68
+ "state",
69
+ "filesProcessed",
70
+ "filesAdded",
71
+ "filesUpdated",
72
+ "filesErrored",
73
+ "filesSkipped",
74
+ "durationMs"
75
+ ],
76
+ "properties": {
77
+ "state": { "$ref": "#/definitions/stageState" },
78
+ "filesProcessed": { "type": "integer", "minimum": 0 },
79
+ "filesAdded": { "type": "integer", "minimum": 0 },
80
+ "filesUpdated": { "type": "integer", "minimum": 0 },
81
+ "filesErrored": {
82
+ "type": "integer",
83
+ "minimum": 0,
84
+ "description": "Per-file errors are counted, not fatal; the stage still completes."
85
+ },
86
+ "filesSkipped": { "type": "integer", "minimum": 0 },
87
+ "durationMs": { "type": "integer", "minimum": 0 },
88
+ "error": { "type": "string" }
89
+ }
90
+ },
91
+ "embedStage": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "required": [
95
+ "state",
96
+ "embedded",
97
+ "errors",
98
+ "contentionErrors",
99
+ "durationMs"
100
+ ],
101
+ "properties": {
102
+ "state": { "$ref": "#/definitions/stageState" },
103
+ "embedded": { "type": "integer", "minimum": 0 },
104
+ "errors": {
105
+ "type": "integer",
106
+ "minimum": 0,
107
+ "description": "Chunks that failed to embed after same-run retries; any non-zero value makes the stage `failed`."
108
+ },
109
+ "contentionErrors": {
110
+ "type": "integer",
111
+ "minimum": 0,
112
+ "description": "Chunks deferred by SQLITE_BUSY/LOCKED - not embedding failures."
113
+ },
114
+ "durationMs": { "type": "integer", "minimum": 0 },
115
+ "reason": {
116
+ "type": "string",
117
+ "description": "Why the stage was skipped (`--no-embed`, `lexical stage failed`)."
118
+ },
119
+ "error": { "type": "string" }
120
+ }
121
+ },
122
+ "interruptedStage": {
123
+ "type": "object",
124
+ "additionalProperties": false,
125
+ "required": ["stage", "state", "startedAt", "pid"],
126
+ "properties": {
127
+ "stage": { "type": "string", "enum": ["lexical", "embed"] },
128
+ "state": { "const": "interrupted" },
129
+ "startedAt": { "type": "string", "format": "date-time" },
130
+ "pid": { "type": "integer", "minimum": 1 },
131
+ "collection": { "type": "string" }
132
+ }
133
+ }
134
+ }
135
+ }
@@ -71,6 +71,82 @@
71
71
  "type": ["integer", "null"],
72
72
  "minimum": 0,
73
73
  "description": "Current size of the log-file in bytes, or null when the file does not exist"
74
+ },
75
+ "findings": {
76
+ "description": "Daemon only: persisted last-run state of the scheduled findings pass, or null when findings.enabled is false / never configured. Read from the data-dir state file, so it survives daemon restarts.",
77
+ "anyOf": [
78
+ { "type": "null" },
79
+ {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "required": [
83
+ "schemaVersion",
84
+ "state",
85
+ "collection",
86
+ "cadence",
87
+ "lastOutcome",
88
+ "lastRunAt",
89
+ "lastSuccessAt",
90
+ "nextDueAt",
91
+ "durationMs",
92
+ "counts",
93
+ "error"
94
+ ],
95
+ "properties": {
96
+ "schemaVersion": { "const": "1.0" },
97
+ "state": {
98
+ "type": "string",
99
+ "enum": [
100
+ "pending",
101
+ "success",
102
+ "failed",
103
+ "skipped_lease",
104
+ "overdue"
105
+ ],
106
+ "description": "lastOutcome, or overdue once nextDueAt has slipped by a full cadence"
107
+ },
108
+ "collection": { "type": "string" },
109
+ "cadence": { "type": "string" },
110
+ "lastOutcome": {
111
+ "type": "string",
112
+ "enum": ["pending", "success", "failed", "skipped_lease"]
113
+ },
114
+ "lastRunAt": { "type": ["string", "null"], "format": "date-time" },
115
+ "lastSuccessAt": {
116
+ "type": ["string", "null"],
117
+ "format": "date-time"
118
+ },
119
+ "nextDueAt": { "type": "string", "format": "date-time" },
120
+ "durationMs": { "type": ["integer", "null"], "minimum": 0 },
121
+ "counts": {
122
+ "anyOf": [
123
+ { "type": "null" },
124
+ {
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "required": [
128
+ "findings",
129
+ "written",
130
+ "reopened",
131
+ "resolved",
132
+ "deleted",
133
+ "open"
134
+ ],
135
+ "properties": {
136
+ "findings": { "type": "integer", "minimum": 0 },
137
+ "written": { "type": "integer", "minimum": 0 },
138
+ "reopened": { "type": "integer", "minimum": 0 },
139
+ "resolved": { "type": "integer", "minimum": 0 },
140
+ "deleted": { "type": "integer", "minimum": 0 },
141
+ "open": { "type": "integer", "minimum": 0 }
142
+ }
143
+ }
144
+ ]
145
+ },
146
+ "error": { "type": ["string", "null"] }
147
+ }
148
+ }
149
+ ]
74
150
  }
75
151
  },
76
152
  "allOf": [
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Streaming read path over the document change journal for
3
+ * `gno changes --follow --jsonl`.
4
+ *
5
+ * Wire contract (spec/cli.md "gno changes"): one JSON object per line. Event
6
+ * lines are `{event, postCursor}` where `postCursor` is the journal cursor
7
+ * after that event was applied; a consumer that persists `postCursor` and
8
+ * resumes with `--cursor <postCursor>` sees nothing before it again. Quiet
9
+ * periods emit nothing. When the resume cursor falls below the retention
10
+ * floor, exactly one terminal `{error: "cursor_expired", earliestCursor,
11
+ * latestCursor}` line is written and the loop returns `expired`.
12
+ */
13
+
14
+ import type { KnowledgeChange } from "../../core/knowledge-delta";
15
+ import type { StorePort } from "../../store/types";
16
+
17
+ import {
18
+ decodeDocumentChangeCursor,
19
+ encodeDocumentChangeCursor,
20
+ } from "../../core/change-journal";
21
+ import { projectKnowledgeChange } from "../../core/knowledge-delta";
22
+
23
+ /** Poll cadence between empty journal reads; pages drain back-to-back. */
24
+ export const FOLLOW_POLL_INTERVAL_MS = 250;
25
+ const FOLLOW_PAGE_SIZE = 500;
26
+
27
+ export interface ChangesFollowEvent {
28
+ event: KnowledgeChange;
29
+ postCursor: string;
30
+ }
31
+
32
+ export interface ChangesFollowExpired {
33
+ error: "cursor_expired";
34
+ earliestCursor: string;
35
+ latestCursor: string;
36
+ }
37
+
38
+ export type ChangesFollowLine = ChangesFollowEvent | ChangesFollowExpired;
39
+
40
+ export interface FollowChangesOptions {
41
+ /** Resume cursor; omitted means start at the journal's latest cursor. */
42
+ cursor?: string;
43
+ collection?: string;
44
+ signal: AbortSignal;
45
+ pollIntervalMs?: number;
46
+ }
47
+
48
+ export type FollowChangesResult =
49
+ | { status: "stopped"; cursor: string }
50
+ | { status: "expired"; earliestCursor: string; latestCursor: string }
51
+ | { status: "error"; error: string; isValidation: boolean };
52
+
53
+ export const validateFollowCursor = (cursor: string): string | null => {
54
+ try {
55
+ decodeDocumentChangeCursor(cursor);
56
+ return null;
57
+ } catch {
58
+ return "cursor must be an opaque change cursor from an earlier response";
59
+ }
60
+ };
61
+
62
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
63
+ new Promise((resolve) => {
64
+ if (signal.aborted) {
65
+ resolve();
66
+ return;
67
+ }
68
+ const onAbort = (): void => {
69
+ clearTimeout(timer);
70
+ resolve();
71
+ };
72
+ const timer = setTimeout(() => {
73
+ signal.removeEventListener("abort", onAbort);
74
+ resolve();
75
+ }, ms);
76
+ signal.addEventListener("abort", onAbort, { once: true });
77
+ });
78
+
79
+ /** Sequence behind an opaque cursor, or `null` when it does not decode. */
80
+ const cursorSequence = (cursor: string): number | null => {
81
+ try {
82
+ return decodeDocumentChangeCursor(cursor);
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Later of two opaque cursors. Each side is decoded on its own: a malformed
90
+ * side yields the other, and when both are malformed `left` wins.
91
+ */
92
+ const maxCursor = (left: string, right: string): string => {
93
+ const leftSequence = cursorSequence(left);
94
+ const rightSequence = cursorSequence(right);
95
+ if (leftSequence === null) return rightSequence === null ? left : right;
96
+ if (rightSequence === null) return left;
97
+ return encodeDocumentChangeCursor(Math.max(leftSequence, rightSequence));
98
+ };
99
+
100
+ /**
101
+ * Stream journal events to `emit` until the signal aborts, the cursor
102
+ * expires, or the store fails. Each event line is emitted after the cursor
103
+ * it carries is final, so the caller can checkpoint per line.
104
+ */
105
+ export async function followChanges(
106
+ store: StorePort,
107
+ options: FollowChangesOptions,
108
+ emit: (line: ChangesFollowLine) => void
109
+ ): Promise<FollowChangesResult> {
110
+ const { signal } = options;
111
+ const pollIntervalMs = options.pollIntervalMs ?? FOLLOW_POLL_INTERVAL_MS;
112
+ let cursor = options.cursor;
113
+
114
+ if (cursor === undefined) {
115
+ const head = await store.listDocumentChanges({ limit: 1 });
116
+ if (!head.ok) {
117
+ return {
118
+ status: "error",
119
+ error: head.error.message,
120
+ isValidation: false,
121
+ };
122
+ }
123
+ cursor = head.value.latestCursor;
124
+ }
125
+
126
+ while (!signal.aborted) {
127
+ const page = await store.listDocumentChanges({
128
+ cursor,
129
+ collection: options.collection,
130
+ limit: FOLLOW_PAGE_SIZE,
131
+ });
132
+ if (!page.ok) {
133
+ return {
134
+ status: "error",
135
+ error: page.error.message,
136
+ isValidation: page.error.code === "INVALID_INPUT",
137
+ };
138
+ }
139
+ if (page.value.cursorExpired) {
140
+ const { earliestCursor, latestCursor } = page.value;
141
+ emit({ error: "cursor_expired", earliestCursor, latestCursor });
142
+ return { status: "expired", earliestCursor, latestCursor };
143
+ }
144
+ let drained = true;
145
+ for (const row of page.value.changes) {
146
+ if (signal.aborted) {
147
+ drained = false;
148
+ break;
149
+ }
150
+ const event = projectKnowledgeChange(row);
151
+ // The change id encodes the sequence that produced it, which is exactly
152
+ // the journal position after applying the event.
153
+ cursor = event.id;
154
+ emit({ event, postCursor: cursor });
155
+ }
156
+ if (page.value.truncated) continue;
157
+ // An untruncated page was scanned to the journal head even when a
158
+ // collection filter emitted nothing from it: advance to that high-water
159
+ // mark so the next poll does not rescan the same tail. Emitted events keep
160
+ // their own postCursor; this only moves the internal resume point.
161
+ if (drained) {
162
+ cursor = maxCursor(cursor, page.value.latestCursor);
163
+ }
164
+ await sleep(pollIntervalMs, signal);
165
+ }
166
+ return { status: "stopped", cursor };
167
+ }