@agent-native/core 0.200.0-nightly-20261002124406 → 0.200.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.
Files changed (48) hide show
  1. package/dist/agent/production-agent.d.ts +5 -1
  2. package/dist/agent/production-agent.js +36 -1
  3. package/dist/app-config/runtime.d.ts +1 -0
  4. package/dist/app-config/runtime.js +4 -0
  5. package/dist/app-config/schema.d.ts +1 -0
  6. package/dist/cli/design-connect.d.ts +1 -0
  7. package/dist/cli/design-connect.js +147 -7
  8. package/dist/client/analytics.js +24 -1
  9. package/dist/client/session-replay.d.ts +6 -0
  10. package/dist/client/session-replay.js +47 -5
  11. package/dist/collab/awareness.d.ts +2 -2
  12. package/dist/collab/routes.d.ts +1 -1
  13. package/dist/file-upload/builder.js +20 -2
  14. package/dist/mcp-client/app-api.d.ts +11 -1
  15. package/dist/mcp-client/app-api.js +17 -3
  16. package/dist/mcp-client/index.d.ts +1 -1
  17. package/dist/mcp-client/manager.d.ts +3 -1
  18. package/dist/mcp-client/manager.js +4 -0
  19. package/dist/notifications/routes.d.ts +3 -3
  20. package/dist/observability/metrics.js +14 -7
  21. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  22. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  23. package/dist/resource-changes/store.d.ts +166 -0
  24. package/dist/resource-changes/store.js +499 -0
  25. package/dist/search/index-store.d.ts +9 -0
  26. package/dist/search/index-store.js +50 -0
  27. package/dist/search/index.d.ts +5 -1
  28. package/dist/search/index.js +5 -0
  29. package/dist/search/indexer.d.ts +32 -0
  30. package/dist/search/indexer.js +651 -0
  31. package/dist/search/query-parser.d.ts +29 -0
  32. package/dist/search/query-parser.js +71 -0
  33. package/dist/search/query.d.ts +46 -0
  34. package/dist/search/query.js +149 -0
  35. package/dist/search/registry.d.ts +55 -0
  36. package/dist/search/registry.js +93 -0
  37. package/dist/search/tokenize.d.ts +103 -0
  38. package/dist/search/tokenize.js +367 -0
  39. package/dist/server/action-change-marker-write.js +5 -1
  40. package/dist/server/agent-chat/run-code-tools.d.ts +7 -0
  41. package/dist/server/agent-chat/run-code-tools.js +7 -0
  42. package/dist/server/agent-chat-plugin.d.ts +5 -1
  43. package/dist/server/agent-chat-plugin.js +18 -12
  44. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  45. package/dist/server/agent-engine-default-model-route.d.ts +2 -2
  46. package/dist/server/release-schema.js +8 -0
  47. package/dist/triggers/actions/manage-automation.d.ts +3 -3
  48. package/package.json +6 -9
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Keeps `search_resources` in step with registered tables by draining the
3
+ * resource change feed. It never starts on its own: drains run before a
4
+ * search, right after a write, and inside the recurring sweep, which are all
5
+ * moments when the database is already awake.
6
+ *
7
+ * Every drain is fenced to its registration's version. When a newer deploy
8
+ * raises the version, statements from older processes stop matching the
9
+ * index state, so they can't claim, write, or complete anything the newer
10
+ * version's rebuild queued.
11
+ */
12
+ import { getAppConfig } from "../app-config/index.js";
13
+ import { getDbExec } from "../db/client.js";
14
+ import { RESOURCE_CHANGE_MAX_ATTEMPTS, claimResourceChanges, completeResourceChanges, enqueueAllResourceChanges, failResourceChanges, hasPendingResourceChanges, resourceChangeBacklogColumns, resourceChangeCaptureInstalled, subscribeResourceChangeConsumer, } from "../resource-changes/store.js";
15
+ import { getRequestRunContext } from "../server/request-context.js";
16
+ import { SEARCH_INDEX_STATE_TABLE, SEARCH_RESOURCES_TABLE, } from "./index-store.js";
17
+ import { SEARCH_CHANGE_CONSUMER, listSearchableResources, searchableResourceSource, } from "./registry.js";
18
+ import { buildSearchVector, normalizeSearchText, utf8ByteLength, } from "./tokenize.js";
19
+ const CLAIM_BATCH = 50;
20
+ const MAX_WRITE_BYTES = 1_500_000;
21
+ const YIELD_AFTER_MS = 20;
22
+ const REBUILD_ENQUEUE_RETRY_MS = 120_000;
23
+ const CAPTURE_RECHECK_MS = 60_000;
24
+ const runtimes = new Map();
25
+ function runtimeFor(registration) {
26
+ const key = `${registration.app}:${registration.type}`;
27
+ let runtime = runtimes.get(key);
28
+ if (!runtime) {
29
+ runtime = {};
30
+ runtimes.set(key, runtime);
31
+ }
32
+ return runtime;
33
+ }
34
+ /** Forgets per-process memos. Tests use it between databases. */
35
+ export function resetSearchIndexRuntime() {
36
+ runtimes.clear();
37
+ }
38
+ function feedFor(registration) {
39
+ return {
40
+ consumer: SEARCH_CHANGE_CONSUMER,
41
+ app: registration.app,
42
+ resourceType: registration.type,
43
+ };
44
+ }
45
+ /** Holds only while the index still targets this registration's version. */
46
+ function versionFence(registration) {
47
+ return {
48
+ sql: `EXISTS (SELECT 1 FROM ${SEARCH_INDEX_STATE_TABLE} AS fence
49
+ WHERE fence.app = ? AND fence.resource_type = ? AND fence.target_version = ?)`,
50
+ args: [registration.app, registration.type, registration.version],
51
+ };
52
+ }
53
+ function notReady(reason) {
54
+ return { ready: false, reason };
55
+ }
56
+ /**
57
+ * Reasons a drain can fix, so a search spends its budget draining. A failing
58
+ * change is among them: claiming respects its backoff, and a failure that
59
+ * was only temporary clears without waiting for a write or the sweep.
60
+ */
61
+ const DRAINABLE = new Set([
62
+ "backlog",
63
+ "rebuilding",
64
+ "failed-changes",
65
+ ]);
66
+ /**
67
+ * Drains pending changes for one registration until `deadline` and reports
68
+ * whether the index is complete and current, meaning a search can trust it.
69
+ * Concurrent calls in one process share a single drain, which finishes the
70
+ * batch it is on before stopping.
71
+ */
72
+ export function drainSearchIndex(registration, deadline) {
73
+ return sharedDrain(registration, deadline);
74
+ }
75
+ function sharedDrain(registration, deadline, known) {
76
+ const runtime = runtimeFor(registration);
77
+ if (runtime.inFlight)
78
+ return runtime.inFlight;
79
+ const run = drain(registration, runtime, deadline, known)
80
+ .catch((error) => {
81
+ console.error(`[search] Draining the ${registration.app}/${registration.type} index failed:`, error instanceof Error ? error.message : String(error));
82
+ return notReady("unavailable");
83
+ })
84
+ .finally(() => {
85
+ if (runtime.inFlight === run)
86
+ runtime.inFlight = undefined;
87
+ });
88
+ runtime.inFlight = run;
89
+ return run;
90
+ }
91
+ /**
92
+ * Call before searching. Reports whether the index can answer: it reflects
93
+ * every change committed before this call and was built by this
94
+ * registration's version. When changes are pending, it spends up to the
95
+ * budget indexing them, and answers by then either way. When the index can't
96
+ * answer (first build, a backlog, a change that keeps failing, missing
97
+ * capture, a newer deploy), use the app's fallback search for this request.
98
+ *
99
+ * `budgetMs` defaults to `runtime.searchDrainBudgetMs`. Zero indexes nothing
100
+ * here and leaves pending changes to the drains that follow writes and the
101
+ * recurring sweep.
102
+ */
103
+ export async function prepareSearchIndex(registration, options = {}) {
104
+ try {
105
+ const exec = getDbExec();
106
+ const runtime = runtimeFor(registration);
107
+ if (!(await captureInstalled(exec, registration, runtime))) {
108
+ return notReady("capture-missing");
109
+ }
110
+ const snapshot = await readSnapshot(exec, registration);
111
+ reportFailing(registration, runtime, snapshot);
112
+ const status = statusOf(registration, snapshot);
113
+ const budgetMs = options.budgetMs ?? getAppConfig().runtime.searchDrainBudgetMs;
114
+ if (status.ready || budgetMs <= 0 || !DRAINABLE.has(status.reason)) {
115
+ return status;
116
+ }
117
+ const deadline = Date.now() + budgetMs;
118
+ let known = snapshot;
119
+ let current = status;
120
+ if (runtime.inFlight) {
121
+ // A drain that was already running may have read the backlog before a
122
+ // change this search must see committed, so its answer isn't this
123
+ // search's. Wait for it, then look again.
124
+ if (!(await settleWithin(runtime.inFlight, budgetMs)))
125
+ return current;
126
+ known = await readSnapshot(exec, registration);
127
+ current = statusOf(registration, known);
128
+ if (current.ready ||
129
+ !DRAINABLE.has(current.reason) ||
130
+ Date.now() >= deadline) {
131
+ return current;
132
+ }
133
+ }
134
+ const started = !runtime.inFlight;
135
+ const drained = sharedDrain(registration, deadline, known);
136
+ const settled = await settleWithin(drained, deadline - Date.now());
137
+ if (settled)
138
+ return settled;
139
+ // A drain this search started stops claiming at the deadline but
140
+ // finishes its batch, so the changes it holds don't wait out their lease.
141
+ if (started)
142
+ getRequestRunContext()?.waitUntil?.(drained);
143
+ return current;
144
+ }
145
+ catch (error) {
146
+ console.error(`[search] Preparing the ${registration.app}/${registration.type} index failed:`, error instanceof Error ? error.message : String(error));
147
+ return notReady("unavailable");
148
+ }
149
+ }
150
+ /** Node fires longer timers at once, so waits this long are unbounded. */
151
+ const MAX_TIMER_MS = 2 ** 31 - 1;
152
+ /** The work's result if it settles within `ms`, otherwise undefined. */
153
+ async function settleWithin(work, ms) {
154
+ if (ms >= MAX_TIMER_MS)
155
+ return work;
156
+ let timer;
157
+ try {
158
+ return await Promise.race([
159
+ work,
160
+ new Promise((resolve) => {
161
+ timer = setTimeout(() => resolve(undefined), ms);
162
+ }),
163
+ ]);
164
+ }
165
+ finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+ /** Drains every registration, in turn, until `deadline`. */
170
+ export async function drainAllSearchIndexes(deadline) {
171
+ for (const registration of listSearchableResources()) {
172
+ if (Date.now() >= deadline)
173
+ return;
174
+ await drainSearchIndex(registration, deadline);
175
+ }
176
+ }
177
+ function statusOf(registration, { state, pending, failing }) {
178
+ if (state && state.targetVersion > registration.version) {
179
+ // A newer deploy owns the index; this process serves the old path.
180
+ return notReady("outdated-registration");
181
+ }
182
+ if (!state ||
183
+ state.targetVersion < registration.version ||
184
+ !state.rebuildCompleted ||
185
+ state.indexVersion !== registration.version) {
186
+ return notReady("rebuilding");
187
+ }
188
+ if (failing)
189
+ return notReady("failed-changes");
190
+ if (pending)
191
+ return notReady("backlog");
192
+ return { ready: true };
193
+ }
194
+ function reportFailing(registration, runtime, snapshot) {
195
+ if (snapshot.failing && !runtime.failingReported) {
196
+ console.error(`[search] Some ${registration.app}/${registration.type} changes keep failing to index, so search is using the app's fallback. ` +
197
+ "They are retried every few minutes; the errors logged while indexing them say why.");
198
+ }
199
+ runtime.failingReported = snapshot.failing;
200
+ }
201
+ async function drain(registration, runtime, deadline, known) {
202
+ const exec = getDbExec();
203
+ if (!(await captureInstalled(exec, registration, runtime))) {
204
+ return notReady("capture-missing");
205
+ }
206
+ let snapshot = known ?? (await readSnapshot(exec, registration));
207
+ if (rebuildNeedsQueueing(registration, snapshot.state)) {
208
+ await startRebuild(exec, registration, snapshot.state);
209
+ snapshot = await readSnapshot(exec, registration);
210
+ }
211
+ if (statusOf(registration, snapshot).reason === "outdated-registration") {
212
+ return notReady("outdated-registration");
213
+ }
214
+ const feed = feedFor(registration);
215
+ const fence = versionFence(registration);
216
+ while (snapshot.pending && Date.now() < deadline) {
217
+ const claimed = await claimResourceChanges(exec, feed, CLAIM_BATCH, fence);
218
+ if (claimed.length) {
219
+ await processBatch(exec, registration, feed, claimed, fence);
220
+ }
221
+ snapshot = await readSnapshot(exec, registration);
222
+ // Nothing to claim: what's left is leased by another drain, waiting to
223
+ // retry, or queued for a newer version.
224
+ if (!claimed.length)
225
+ break;
226
+ }
227
+ const { state } = snapshot;
228
+ if (state &&
229
+ state.targetVersion === registration.version &&
230
+ !state.rebuildCompleted &&
231
+ (await completeRebuildIfDone(exec, registration, state))) {
232
+ snapshot = await readSnapshot(exec, registration);
233
+ }
234
+ return statusOf(registration, snapshot);
235
+ }
236
+ /**
237
+ * Whether the table's change capture is installed and enabled, checked at
238
+ * most once a minute per process. Writes made while it isn't were never
239
+ * recorded, so finding it missing also discards the finished index: once
240
+ * capture is back, the index rebuilds from the table.
241
+ */
242
+ async function captureInstalled(exec, registration, runtime) {
243
+ if (runtime.captureVerifiedAt !== undefined &&
244
+ Date.now() - runtime.captureVerifiedAt < CAPTURE_RECHECK_MS) {
245
+ return runtime.captureInstalled === true;
246
+ }
247
+ const source = searchableResourceSource(registration);
248
+ const installed = await resourceChangeCaptureInstalled(exec, source);
249
+ if (!installed) {
250
+ await invalidateIndex(exec, registration);
251
+ if (runtime.captureInstalled !== false) {
252
+ console.error(`[search] Change capture for ${registration.app}/${registration.type} is missing or disabled on table "${source.table}". ` +
253
+ "Add searchIndexMigration() for it to the app's runMigrations list, or re-enable its triggers. " +
254
+ "Search is using the app's fallback, and rebuilds the index once capture is back.");
255
+ }
256
+ }
257
+ else if (!runtime.captureInstalled) {
258
+ await subscribeResourceChangeConsumer(exec, source, SEARCH_CHANGE_CONSUMER);
259
+ }
260
+ runtime.captureInstalled = installed;
261
+ runtime.captureVerifiedAt = Date.now();
262
+ return installed;
263
+ }
264
+ /**
265
+ * Marks the index as needing a rebuild at its current version. Nothing to do
266
+ * before the search tables exist: there is no index yet.
267
+ */
268
+ async function invalidateIndex(exec, registration) {
269
+ const { rows } = await exec.execute({
270
+ sql: `SELECT to_regclass(?) IS NOT NULL AS present`,
271
+ args: [SEARCH_INDEX_STATE_TABLE],
272
+ });
273
+ const [table] = rows;
274
+ if (!table)
275
+ throw new Error("Looking up the search index table returned no row.");
276
+ if (!flag(table.present))
277
+ return;
278
+ await exec.execute({
279
+ sql: `UPDATE ${SEARCH_INDEX_STATE_TABLE}
280
+ SET rebuild_high_seq = NULL, rebuild_started_at = NULL, rebuild_completed_at = NULL
281
+ WHERE app = ? AND resource_type = ? AND rebuild_high_seq IS NOT NULL`,
282
+ args: [registration.app, registration.type],
283
+ });
284
+ }
285
+ function flag(value) {
286
+ return value === true || value === "t" || value === "true";
287
+ }
288
+ async function readSnapshot(exec, registration) {
289
+ const backlog = resourceChangeBacklogColumns(feedFor(registration));
290
+ const { rows } = await exec.execute({
291
+ sql: `SELECT s.target_version, s.index_version, s.rebuild_high_seq::text AS rebuild_high_seq,
292
+ s.rebuild_started_at, s.rebuild_completed_at, ${backlog.sql}
293
+ FROM (SELECT 1) AS one
294
+ LEFT JOIN ${SEARCH_INDEX_STATE_TABLE} AS s ON s.app = ? AND s.resource_type = ?`,
295
+ args: [...backlog.args, registration.app, registration.type],
296
+ });
297
+ const row = rows[0] ?? {};
298
+ return {
299
+ state: row.target_version == null
300
+ ? null
301
+ : {
302
+ targetVersion: Number(row.target_version),
303
+ indexVersion: row.index_version == null ? null : Number(row.index_version),
304
+ rebuildHighSeq: row.rebuild_high_seq == null
305
+ ? null
306
+ : String(row.rebuild_high_seq),
307
+ rebuildStartedAt: row.rebuild_started_at
308
+ ? new Date(row.rebuild_started_at).getTime()
309
+ : null,
310
+ rebuildCompleted: row.rebuild_completed_at != null,
311
+ },
312
+ pending: flag(row.pending),
313
+ failing: flag(row.failing),
314
+ };
315
+ }
316
+ /**
317
+ * A registration whose version is newer than the index state starts a
318
+ * rebuild. So does one whose index was discarded because capture went
319
+ * missing, and one whose rebuild was started by a process that died before
320
+ * queueing it.
321
+ */
322
+ function rebuildNeedsQueueing(registration, state) {
323
+ if (!state || state.targetVersion < registration.version)
324
+ return true;
325
+ return (state.targetVersion === registration.version &&
326
+ !state.rebuildCompleted &&
327
+ state.rebuildHighSeq === null &&
328
+ (state.rebuildStartedAt ?? 0) < Date.now() - REBUILD_ENQUEUE_RETRY_MS);
329
+ }
330
+ /**
331
+ * Raises the index's target version, or claims a rebuild at the current
332
+ * one, then queues every source row once. Only one process wins either, and
333
+ * from a version bump on, the fence stops older processes. Queueing replaces
334
+ * changes they already hold, so none of their in-flight work can complete
335
+ * what the rebuild queued.
336
+ */
337
+ async function startRebuild(exec, registration, state) {
338
+ const { rows } = !state || state.targetVersion < registration.version
339
+ ? await exec.execute({
340
+ sql: `INSERT INTO ${SEARCH_INDEX_STATE_TABLE} (app, resource_type, target_version, rebuild_started_at)
341
+ VALUES (?, ?, ?, now())
342
+ ON CONFLICT (app, resource_type) DO UPDATE SET
343
+ target_version = EXCLUDED.target_version,
344
+ rebuild_started_at = EXCLUDED.rebuild_started_at,
345
+ rebuild_high_seq = NULL,
346
+ rebuild_completed_at = NULL
347
+ WHERE ${SEARCH_INDEX_STATE_TABLE}.target_version < EXCLUDED.target_version
348
+ RETURNING target_version`,
349
+ args: [registration.app, registration.type, registration.version],
350
+ })
351
+ : await exec.execute({
352
+ sql: `UPDATE ${SEARCH_INDEX_STATE_TABLE} SET rebuild_started_at = now()
353
+ WHERE app = ? AND resource_type = ? AND target_version = ?
354
+ AND rebuild_high_seq IS NULL AND rebuild_completed_at IS NULL
355
+ AND (rebuild_started_at IS NULL OR rebuild_started_at < now() - make_interval(secs => ?))
356
+ RETURNING target_version`,
357
+ args: [
358
+ registration.app,
359
+ registration.type,
360
+ registration.version,
361
+ REBUILD_ENQUEUE_RETRY_MS / 1000,
362
+ ],
363
+ });
364
+ if (!rows.length)
365
+ return;
366
+ const highSeq = await enqueueAllResourceChanges(exec, searchableResourceSource(registration), SEARCH_CHANGE_CONSUMER, "rebuild");
367
+ await exec.execute({
368
+ sql: `UPDATE ${SEARCH_INDEX_STATE_TABLE} SET rebuild_high_seq = ?::bigint
369
+ WHERE app = ? AND resource_type = ? AND target_version = ?`,
370
+ args: [highSeq, registration.app, registration.type, registration.version],
371
+ });
372
+ }
373
+ /**
374
+ * Marks the rebuild complete once every change it queued is processed.
375
+ * Returns whether the state may have changed.
376
+ */
377
+ async function completeRebuildIfDone(exec, registration, state) {
378
+ if (state.rebuildHighSeq === null)
379
+ return false;
380
+ const pending = await hasPendingResourceChanges(exec, feedFor(registration), {
381
+ atOrBelowSeq: state.rebuildHighSeq,
382
+ });
383
+ if (pending)
384
+ return false;
385
+ const { rows } = await exec.execute({
386
+ sql: `UPDATE ${SEARCH_INDEX_STATE_TABLE}
387
+ SET index_version = target_version, rebuild_completed_at = now()
388
+ WHERE app = ? AND resource_type = ? AND target_version = ? AND rebuild_completed_at IS NULL
389
+ RETURNING target_version`,
390
+ args: [registration.app, registration.type, state.targetVersion],
391
+ });
392
+ if (rows.length) {
393
+ // Rows whose source row is gone are deletes the feed missed: the source
394
+ // table changed between versions, or capture was off. Rows a newer
395
+ // version wrote are never this rebuild's to judge.
396
+ const source = searchableResourceSource(registration);
397
+ const fence = versionFence(registration);
398
+ await exec.execute({
399
+ sql: `DELETE FROM ${SEARCH_RESOURCES_TABLE} AS sr
400
+ WHERE sr.app = ? AND sr.resource_type = ? AND sr.index_version <= ?
401
+ AND NOT EXISTS (SELECT 1 FROM "${source.table}" AS src WHERE src."${source.idColumn}"::text = sr.resource_id)
402
+ AND ${fence.sql}`,
403
+ args: [
404
+ registration.app,
405
+ registration.type,
406
+ state.targetVersion,
407
+ ...fence.args,
408
+ ],
409
+ });
410
+ }
411
+ return true;
412
+ }
413
+ /**
414
+ * Indexes a claimed batch. A change that has failed before is retried on its
415
+ * own, and when a batch fails, each of its changes is retried alone, so one
416
+ * bad resource only ever holds back itself.
417
+ */
418
+ async function processBatch(exec, registration, feed, claimed, fence) {
419
+ const fresh = claimed.filter((change) => change.attempts <= 1);
420
+ const retries = claimed.filter((change) => change.attempts > 1);
421
+ if (fresh.length)
422
+ await indexGroup(exec, registration, feed, fresh, fence);
423
+ for (const change of retries) {
424
+ await indexGroup(exec, registration, feed, [change], fence);
425
+ }
426
+ }
427
+ async function indexGroup(exec, registration, feed, changes, fence) {
428
+ const label = `${registration.app}/${registration.type}`;
429
+ try {
430
+ await indexChanges(exec, registration, feed, changes, fence);
431
+ }
432
+ catch (error) {
433
+ const message = error instanceof Error ? error.message : String(error);
434
+ if (changes.length > 1) {
435
+ console.warn(`[search] Indexing ${changes.length} ${label} changes together failed, so each is retried alone:`, message);
436
+ for (const change of changes) {
437
+ await indexGroup(exec, registration, feed, [change], fence);
438
+ }
439
+ return;
440
+ }
441
+ const change = changes[0];
442
+ const final = change.attempts >= RESOURCE_CHANGE_MAX_ATTEMPTS;
443
+ console.error(`[search] Indexing ${label} "${change.resourceId}" failed on attempt ${change.attempts}` +
444
+ (final ? "; search uses the app's fallback until it succeeds" : "") +
445
+ ":", message);
446
+ // If recording the failure fails too, the claim's lease still delays the
447
+ // next attempt, so say so and let the drain go on.
448
+ await failResourceChanges(exec, feed, changes, fence).catch((backoffError) => {
449
+ console.error(`[search] Recording the failure of ${label} "${change.resourceId}" failed, so it is retried when its claim expires:`, backoffError instanceof Error
450
+ ? backoffError.message
451
+ : String(backoffError));
452
+ });
453
+ }
454
+ }
455
+ async function indexChanges(exec, registration, feed, changes, fence) {
456
+ const documents = await registration.load(changes.map((c) => c.resourceId));
457
+ const byId = new Map(documents.map((document) => [document.id, document]));
458
+ const stored = await storedHashes(exec, registration, changes);
459
+ const writes = [];
460
+ const unchanged = [];
461
+ const removed = [];
462
+ for (const change of changes) {
463
+ const document = byId.get(change.resourceId);
464
+ if (!document) {
465
+ removed.push(change);
466
+ continue;
467
+ }
468
+ const hash = contentHash(registration.version, document);
469
+ const row = { change, document, hash };
470
+ if (stored.get(change.resourceId) === hash)
471
+ unchanged.push(row);
472
+ else
473
+ writes.push(row);
474
+ }
475
+ // An unchanged row can vanish before its seq moves on: another drain may
476
+ // be removing it for the delete that came before a recreation. Any row the
477
+ // bump didn't reach is written in full.
478
+ const bumped = await bumpSeq(exec, registration, unchanged.map((row) => row.change), fence);
479
+ writes.push(...unchanged.filter((row) => !bumped.has(row.change.resourceId)));
480
+ await writeRows(exec, registration, writes, fence);
481
+ await removeRows(exec, registration, removed, fence);
482
+ await completeResourceChanges(exec, feed, changes, fence);
483
+ }
484
+ function placeholders(count) {
485
+ return Array.from({ length: count }, () => "?").join(", ");
486
+ }
487
+ async function storedHashes(exec, registration, claimed) {
488
+ const { rows } = await exec.execute({
489
+ sql: `SELECT resource_id, content_hash FROM ${SEARCH_RESOURCES_TABLE}
490
+ WHERE app = ? AND resource_type = ? AND resource_id IN (${placeholders(claimed.length)})`,
491
+ args: [
492
+ registration.app,
493
+ registration.type,
494
+ ...claimed.map((c) => c.resourceId),
495
+ ],
496
+ });
497
+ return new Map(rows.map((row) => [String(row.resource_id), String(row.content_hash)]));
498
+ }
499
+ function modifiedAt(value) {
500
+ if (value == null || value === "")
501
+ return null;
502
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
503
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
504
+ }
505
+ /** cyrb53: a fast 53-bit string hash, enough to skip unchanged rewrites. */
506
+ function hash53(value, seed = 0) {
507
+ let h1 = 0xdeadbeef ^ seed;
508
+ let h2 = 0x41c6ce57 ^ seed;
509
+ for (let index = 0; index < value.length; index += 1) {
510
+ const code = value.charCodeAt(index);
511
+ h1 = Math.imul(h1 ^ code, 2654435761);
512
+ h2 = Math.imul(h2 ^ code, 1597334677);
513
+ }
514
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
515
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
516
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
517
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
518
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
519
+ }
520
+ function contentHash(version, document) {
521
+ const text = [
522
+ document.title,
523
+ document.summary ?? "",
524
+ document.body ?? "",
525
+ modifiedAt(document.modifiedAt) ?? "",
526
+ ].join("\u0000");
527
+ return `v${version}:${text.length}:${hash53(text)}`;
528
+ }
529
+ /**
530
+ * Upserts index rows. A row is only replaced by one from a later change at
531
+ * the same or a newer version, so neither a slow drain nor an older deploy
532
+ * can overwrite newer work.
533
+ */
534
+ async function writeRows(exec, registration, rows, fence) {
535
+ let batch = [];
536
+ let batchBytes = 0;
537
+ const flush = async () => {
538
+ if (!batch.length)
539
+ return;
540
+ const values = batch
541
+ .map(() => "(?, ?, ?, ?, ?, ?, ?::tsvector, ?::boolean, ?::timestamptz, ?, ?::integer, ?::bigint)")
542
+ .join(", ");
543
+ await exec.execute({
544
+ sql: `INSERT INTO ${SEARCH_RESOURCES_TABLE}
545
+ (app, resource_type, resource_id, title, title_norm, summary_norm, doc_vector,
546
+ positions_complete, modified_at, content_hash, index_version, indexed_seq, indexed_at)
547
+ SELECT v.*, now() FROM (VALUES ${values}) AS v
548
+ WHERE ${fence.sql}
549
+ ON CONFLICT (app, resource_type, resource_id) DO UPDATE SET
550
+ title = EXCLUDED.title,
551
+ title_norm = EXCLUDED.title_norm,
552
+ summary_norm = EXCLUDED.summary_norm,
553
+ doc_vector = EXCLUDED.doc_vector,
554
+ positions_complete = EXCLUDED.positions_complete,
555
+ modified_at = EXCLUDED.modified_at,
556
+ content_hash = EXCLUDED.content_hash,
557
+ index_version = EXCLUDED.index_version,
558
+ indexed_seq = EXCLUDED.indexed_seq,
559
+ indexed_at = EXCLUDED.indexed_at
560
+ WHERE ${SEARCH_RESOURCES_TABLE}.indexed_seq <= EXCLUDED.indexed_seq
561
+ AND ${SEARCH_RESOURCES_TABLE}.index_version <= EXCLUDED.index_version`,
562
+ args: [...batch.flatMap((row) => row.args), ...fence.args],
563
+ });
564
+ batch = [];
565
+ batchBytes = 0;
566
+ };
567
+ let yieldedAt = Date.now();
568
+ for (const { change, document, hash } of rows) {
569
+ // Tokenizing is synchronous, so a batch of large documents could hold
570
+ // the event loop past a search's budget; let its timer fire between them.
571
+ if (Date.now() - yieldedAt >= YIELD_AFTER_MS) {
572
+ await new Promise((resolve) => setTimeout(resolve, 0));
573
+ yieldedAt = Date.now();
574
+ }
575
+ const vector = buildSearchVector([
576
+ { text: document.title, weight: "A" },
577
+ { text: document.summary, weight: "B" },
578
+ { text: document.body, weight: "C" },
579
+ ]);
580
+ const title = document.title ?? "";
581
+ const titleNorm = normalizeSearchText(title);
582
+ const summaryNorm = normalizeSearchText(document.summary ?? "");
583
+ // One row can exceed the limit alone; it is then written alone.
584
+ const bytes = utf8ByteLength(vector.literal) +
585
+ utf8ByteLength(title) +
586
+ utf8ByteLength(titleNorm) +
587
+ utf8ByteLength(summaryNorm);
588
+ if (batch.length && batchBytes + bytes > MAX_WRITE_BYTES)
589
+ await flush();
590
+ batch.push({
591
+ bytes,
592
+ args: [
593
+ registration.app,
594
+ registration.type,
595
+ change.resourceId,
596
+ title,
597
+ titleNorm,
598
+ summaryNorm,
599
+ vector.literal,
600
+ vector.positionsComplete,
601
+ modifiedAt(document.modifiedAt),
602
+ hash,
603
+ registration.version,
604
+ change.seq,
605
+ ],
606
+ });
607
+ batchBytes += bytes;
608
+ }
609
+ await flush();
610
+ }
611
+ /** Moves unchanged rows up to their change's seq. Returns the ids it moved. */
612
+ async function bumpSeq(exec, registration, changes, fence) {
613
+ if (!changes.length)
614
+ return new Set();
615
+ const { rows } = await exec.execute({
616
+ sql: `UPDATE ${SEARCH_RESOURCES_TABLE} AS sr SET indexed_seq = seen.seq, indexed_at = now()
617
+ FROM (VALUES ${changes.map(() => "(?, ?::bigint)").join(", ")}) AS seen (resource_id, seq)
618
+ WHERE sr.app = ? AND sr.resource_type = ?
619
+ AND sr.resource_id IN (${placeholders(changes.length)})
620
+ AND sr.resource_id = seen.resource_id AND sr.indexed_seq < seen.seq
621
+ AND ${fence.sql}
622
+ RETURNING sr.resource_id`,
623
+ args: [
624
+ ...changes.flatMap((change) => [change.resourceId, change.seq]),
625
+ registration.app,
626
+ registration.type,
627
+ ...changes.map((change) => change.resourceId),
628
+ ...fence.args,
629
+ ],
630
+ });
631
+ return new Set(rows.map((row) => String(row.resource_id)));
632
+ }
633
+ async function removeRows(exec, registration, changes, fence) {
634
+ if (!changes.length)
635
+ return;
636
+ await exec.execute({
637
+ sql: `DELETE FROM ${SEARCH_RESOURCES_TABLE} AS sr
638
+ USING (VALUES ${changes.map(() => "(?, ?::bigint)").join(", ")}) AS gone (resource_id, seq)
639
+ WHERE sr.app = ? AND sr.resource_type = ?
640
+ AND sr.resource_id IN (${placeholders(changes.length)})
641
+ AND sr.resource_id = gone.resource_id AND sr.indexed_seq <= gone.seq
642
+ AND ${fence.sql}`,
643
+ args: [
644
+ ...changes.flatMap((change) => [change.resourceId, change.seq]),
645
+ registration.app,
646
+ registration.type,
647
+ ...changes.map((change) => change.resourceId),
648
+ ...fence.args,
649
+ ],
650
+ });
651
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The search query language, shared by every search surface: the browser
3
+ * title lane, the server index, and agents.
4
+ *
5
+ * - Words are ANDed: every word must match.
6
+ * - `OR` (uppercase) between words makes them alternatives.
7
+ * - `"quoted text"` is a phrase.
8
+ * - `-word` or `-"phrase"` excludes.
9
+ * - `intitle:word` matches the title only.
10
+ *
11
+ * Pure and dependency-free, so browser code can import it from
12
+ * `@agent-native/core/search-query`.
13
+ */
14
+ export interface SearchQueryTerm {
15
+ text: string;
16
+ phrase: boolean;
17
+ negated: boolean;
18
+ titleOnly: boolean;
19
+ }
20
+ export interface SearchQueryGroup {
21
+ terms: SearchQueryTerm[];
22
+ }
23
+ export interface ParsedSearchQuery {
24
+ groups: SearchQueryGroup[];
25
+ negatives: SearchQueryTerm[];
26
+ empty: boolean;
27
+ }
28
+ export declare function parseSearchQuery(input: string): ParsedSearchQuery;
29
+ export declare function searchQueryNeedles(parsed: ParsedSearchQuery): string[];