@withone/cli 1.50.0 → 1.52.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.
package/README.md CHANGED
@@ -364,10 +364,17 @@ one mem update <id> '{"status":"done"}' # merges into data; refresh
364
364
  one mem search "design review" # hybrid FTS + semantic (if key set)
365
365
  one mem list note --limit 20
366
366
 
367
- # Identity keys are a first-class column (unique across ACTIVE records; archiving
368
- # frees them). Manage them after creation with `mem key` — NOT via `mem update`.
367
+ # Merge keys (`keys[]`) are a first-class column (unique across ACTIVE records;
368
+ # archiving frees them). Manage them after creation with `mem key` — NOT `mem update`.
369
369
  one mem key <id> --add email:x@y.com # also --remove / --set <csv>
370
370
 
371
+ # Find every record tied to a person/company, grouped by type. Spans BOTH columns:
372
+ # `keys[]` (this record IS the entity — drives merge) and `identity_keys[]` (this
373
+ # record INVOLVES the entity — Gmail thread participants, calendar attendees; never merges).
374
+ one mem find-by-key email:jane@acme.com # every record carrying the key
375
+ one mem find-by-key email:jane@acme.com --type gmail/gmailThreads
376
+ one mem find-by-key email:jane@acme.com email:bob@acme.com # intersection — records with BOTH
377
+
371
378
  # Backfill searchable_text for rows that landed NULL or with UUID-noise-heavy text
372
379
  # (drops ids/timestamps, leads with name/title/email). No embedding provider needed.
373
380
  one mem reindex --searchable --type attio/attioPeople
@@ -389,7 +396,9 @@ one mem status # backend, provider, _upgra
389
396
  one mem doctor # 7-check health report
390
397
  ```
391
398
 
392
- Key surfaces: `add`, `get`, `update`, `archive`, `list`, `search` (`--deep` forces semantic), `context`, `link`/`linked`/`unlink`, `sources`, `find-by-source`, `export`, `import`, `migrate`, `vacuum`, `reindex`. Run `one guide memory` for the full reference.
399
+ Key surfaces: `add`, `get`, `update`, `archive`, `list`, `search` (`--deep` forces semantic), `context`, `link`/`linked`/`unlink`, `key`, `sources`, `find-by-source`, `find-by-key`, `export`, `import`, `migrate`, `vacuum`, `reindex`. Run `one guide memory` for the full reference.
400
+
401
+ `find-by-key` is the cross-platform join: keys are matched **case-insensitively** (they are lowercased on write, and the query is lowercased to match), `--type` narrows to one record type, and `--limit` (default 10) caps how many records are *shown per type* while the reported per-type `count` and overall `total` stay full match counts. Above 2000 matches the response flags `truncated` and the counts become floors — re-run with `--type` for an exact answer. Populate `identity_keys[]` from a sync profile's `identityKeys` field (see `one guide sync`), which ships pre-wired for `gmail/gmailThreads`, `google-calendar/events`, and `fathom/meetings`.
393
402
 
394
403
  ### `one sync` (and `one mem sync` alias)
395
404
 
@@ -412,6 +421,10 @@ one sync test stripe/balanceTransactions --show-searchable
412
421
  # Run — every row lands in memory (SQLite also written for enrich-phase compat)
413
422
  one sync run stripe --since 90d
414
423
  one mem sync run stripe # identical (alias)
424
+ # Profiles with `enrich` run a second detail pass. It enriches each record ONCE;
425
+ # the list pass thereafter merges rather than replaces, so the enriched payload
426
+ # survives (reported as `memPreserved`). `--full-refresh` does not re-enrich —
427
+ # delete .one/sync/data/<platform>.db to force that.
415
428
 
416
429
  # Query + search (reads from memory)
417
430
  one sync query stripe/balanceTransactions --where "status=available" --limit 20
@@ -1039,6 +1039,7 @@ var FLOW_SCHEMA = {
1039
1039
  command: { type: "string", required: true, description: "Shell command to execute (supports selectors)" },
1040
1040
  timeout: { type: "number", required: false, description: "Timeout in ms (default: 30000)" },
1041
1041
  parseJson: { type: "boolean", required: false, description: "Parse stdout as JSON (default: false). When true, $.steps.<id>.output is the parsed object/array; when false, it is the trimmed stdout string." },
1042
+ parseEnvelope: { type: "boolean", required: false, description: "Unwrap a `claude --print --output-format json` envelope: extract .result, strip code fences + preamble, parse the inner JSON as $.steps.<id>.output. Use this (instead of parseJson) for claude --print steps. Fails the step if the unwrapped payload isn't valid JSON." },
1042
1043
  cwd: { type: "string", required: false, description: "Working directory (supports selectors)" },
1043
1044
  env: { type: "object", required: false, description: "Additional environment variables" }
1044
1045
  },
@@ -1049,7 +1050,7 @@ var FLOW_SCHEMA = {
1049
1050
  bash: {
1050
1051
  command: "cat /tmp/data.json | claude --print 'Analyze this data' --output-format json",
1051
1052
  timeout: 18e4,
1052
- parseJson: true
1053
+ parseEnvelope: true
1053
1054
  }
1054
1055
  }
1055
1056
  }
@@ -1986,6 +1987,40 @@ function stripCodeFences(text) {
1986
1987
  const match = trimmed.match(/^```(?:\w*)\s*\n([\s\S]*?)\n\s*```\s*$/);
1987
1988
  return match ? match[1].trim() : trimmed;
1988
1989
  }
1990
+ function stripToJson(text) {
1991
+ const s = stripCodeFences(text);
1992
+ const candidates = [s.indexOf("{"), s.indexOf("[")].filter((i) => i >= 0);
1993
+ if (candidates.length === 0) return s;
1994
+ const start = Math.min(...candidates);
1995
+ const end = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
1996
+ return end > start ? s.slice(start, end + 1) : s;
1997
+ }
1998
+ function unwrapClaudeEnvelope(stdout, stepId) {
1999
+ const trimmed = stdout.trim();
2000
+ let envelope;
2001
+ try {
2002
+ envelope = JSON.parse(trimmed);
2003
+ } catch {
2004
+ envelope = trimmed;
2005
+ }
2006
+ let resultText;
2007
+ if (envelope && typeof envelope === "object" && !Array.isArray(envelope) && envelope.type === "result" && typeof envelope.result === "string") {
2008
+ resultText = envelope.result;
2009
+ } else if (typeof envelope === "string") {
2010
+ resultText = envelope;
2011
+ } else {
2012
+ return envelope;
2013
+ }
2014
+ const payload = stripToJson(resultText);
2015
+ try {
2016
+ return JSON.parse(payload);
2017
+ } catch (err) {
2018
+ const snippet = payload.length > 200 ? `${payload.slice(0, 200)}\u2026` : payload;
2019
+ throw new Error(
2020
+ `Bash step "${stepId}" parseEnvelope: claude output was not valid JSON after unwrapping (${err instanceof Error ? err.message : String(err)}). Got: ${snippet}`
2021
+ );
2022
+ }
2023
+ }
1989
2024
  async function executeActionStep(step, context, api, permissions, allowedActionIds, options) {
1990
2025
  const action = step.action;
1991
2026
  const platform = resolveValue(action.platform, context);
@@ -2319,7 +2354,7 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
2319
2354
  if (flowStack.includes(resolvedKey)) {
2320
2355
  throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
2321
2356
  }
2322
- const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-YDKAYGZW.js");
2357
+ const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-WZIYXW47.js");
2323
2358
  const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
2324
2359
  const subContext = await executeFlow(
2325
2360
  subFlow,
@@ -2464,7 +2499,7 @@ async function executeBashStep(step, context, options) {
2464
2499
  env,
2465
2500
  maxBuffer: 10 * 1024 * 1024
2466
2501
  });
2467
- const output = config.parseJson ? JSON.parse(stripCodeFences(stdout)) : stdout.trim();
2502
+ const output = config.parseEnvelope ? unwrapClaudeEnvelope(stdout, step.id) : config.parseJson ? JSON.parse(stripCodeFences(stdout)) : stdout.trim();
2468
2503
  return {
2469
2504
  status: "success",
2470
2505
  output,
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-CLJFSFFM.js";
7
7
  import {
8
8
  getBackend
9
- } from "./chunk-7RV7T5PX.js";
9
+ } from "./chunk-Z4KAQGIG.js";
10
10
 
11
11
  // src/commands/mem/sql.ts
12
12
  async function memSqlCommand(sql) {
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-SO323PZP.js";
12
12
 
13
13
  // src/lib/memory/schema.ts
14
- var SCHEMA_VERSION = "2.2.0";
14
+ var SCHEMA_VERSION = "2.3.1";
15
15
  var EXTENSIONS_SQL = ``;
16
16
  var VECTOR_EXTENSION_SQL = `
17
17
  CREATE EXTENSION IF NOT EXISTS vector;
@@ -24,6 +24,14 @@ CREATE TABLE IF NOT EXISTS mem_records (
24
24
  data JSONB NOT NULL,
25
25
  tags TEXT[],
26
26
  keys TEXT[],
27
+ -- Cross-platform identity keys (#128): queryable associations like
28
+ -- email:jane@acme.com for every participant of a record. UNLIKE keys[],
29
+ -- these are NOT merge identifiers \u2014 they are exempt from the
30
+ -- key-uniqueness trigger and the upsert overlap-merge, so a Gmail thread
31
+ -- carrying many participant emails stays its own record instead of
32
+ -- collapsing into a contact (or into every other thread that shares a
33
+ -- participant). Queried by "mem find-by-key".
34
+ identity_keys TEXT[],
27
35
 
28
36
  sources JSONB NOT NULL DEFAULT '{}',
29
37
 
@@ -72,6 +80,12 @@ CREATE TABLE IF NOT EXISTS mem_meta (
72
80
  value TEXT NOT NULL,
73
81
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
74
82
  );
83
+
84
+ -- Additive migration for stores created before identity_keys existed (#128).
85
+ -- Safe to re-run; existing rows get identity_keys populated lazily on their
86
+ -- next sync. CREATE TABLE IF NOT EXISTS above won't add columns to a table
87
+ -- that already exists, so the ALTER is required for upgrades.
88
+ ALTER TABLE mem_records ADD COLUMN IF NOT EXISTS identity_keys TEXT[];
75
89
  `;
76
90
  var VECTOR_COLUMNS_SQL = `
77
91
  ALTER TABLE mem_records ADD COLUMN IF NOT EXISTS embedding vector(1536);
@@ -82,6 +96,7 @@ var INDEXES_SQL = `
82
96
  CREATE INDEX IF NOT EXISTS idx_records_type ON mem_records(type);
83
97
  CREATE INDEX IF NOT EXISTS idx_records_status ON mem_records(status);
84
98
  CREATE INDEX IF NOT EXISTS idx_records_keys ON mem_records USING GIN(keys);
99
+ CREATE INDEX IF NOT EXISTS idx_records_identity_keys ON mem_records USING GIN(identity_keys);
85
100
  CREATE INDEX IF NOT EXISTS idx_records_tags ON mem_records USING GIN(tags);
86
101
  CREATE INDEX IF NOT EXISTS idx_records_data ON mem_records USING GIN(data jsonb_path_ops);
87
102
  CREATE INDEX IF NOT EXISTS idx_records_sources ON mem_records USING GIN(sources jsonb_path_ops);
@@ -105,6 +120,25 @@ BEGIN
105
120
  END $$;
106
121
  `;
107
122
  var FUNCTIONS_SQL = `
123
+ -- Drop the pre-#128 11-argument mem_upsert_by_keys (no p_identity_keys).
124
+ --
125
+ -- CREATE OR REPLACE cannot change a function's argument list \u2014 adding
126
+ -- \`p_identity_keys TEXT[] DEFAULT NULL\` defines a SECOND function rather
127
+ -- than replacing the old one. An upgraded store would then hold both
128
+ -- overloads in pg_proc, and because the 12-arg version's trailing param is
129
+ -- defaultable, an 11-arg call matches BOTH and fails with 42725
130
+ -- "function mem_upsert_by_keys(...) is not unique". Verified on PGlite.
131
+ --
132
+ -- The explicit argument list makes this unambiguous (no 42725 on the DROP
133
+ -- itself) and leaves the 12-arg version untouched, so re-running the block
134
+ -- is a no-op. Only needed here, not in VECTOR_FUNCTIONS_SQL: the vector
135
+ -- variant is a same-signature CREATE OR REPLACE that always runs AFTER this
136
+ -- block (ensureSchema and getFullSchemaSQL both order core functions before
137
+ -- vector functions), so by the time it runs the stale 11-arg entry \u2014 whether
138
+ -- it was last written by the core or the vector block \u2014 is already gone.
139
+ DROP FUNCTION IF EXISTS mem_upsert_by_keys(
140
+ TEXT, JSONB, TEXT[], TEXT[], JSONB, TEXT, TEXT, INTEGER, TEXT, TEXT, BOOLEAN);
141
+
108
142
  -- Enforce uniqueness of the "keys" array across ACTIVE records only.
109
143
  --
110
144
  -- Scoping to active is deliberate: an archived record must not squat on a
@@ -227,7 +261,8 @@ CREATE OR REPLACE FUNCTION mem_upsert_by_keys(
227
261
  p_weight INTEGER DEFAULT NULL,
228
262
  p_embedding TEXT DEFAULT NULL,
229
263
  p_embedding_model TEXT DEFAULT NULL,
230
- p_replace BOOLEAN DEFAULT FALSE
264
+ p_replace BOOLEAN DEFAULT FALSE,
265
+ p_identity_keys TEXT[] DEFAULT NULL
231
266
  ) RETURNS TABLE (id UUID, action TEXT) LANGUAGE plpgsql AS $$
232
267
  DECLARE
233
268
  existing_id UUID;
@@ -262,6 +297,23 @@ BEGIN
262
297
  ELSE (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.tags, '{}') || COALESCE(p_tags, '{}'))))
263
298
  END,
264
299
  keys = (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.keys, '{}') || COALESCE(p_keys, '{}')))),
300
+ -- identity_keys: replace on a replace-sync, otherwise union (never
301
+ -- drop an association). NOT part of the overlap-merge above.
302
+ -- Three states, and NULL is NOT the same as '{}' here:
303
+ -- NULL \u2192 caller has no opinion (profile declares no
304
+ -- identityKeys, or the record is still the pre-enrich
305
+ -- shape whose participant fields haven't been fetched
306
+ -- yet) \u2192 KEEP what's stored. Clearing on NULL made
307
+ -- every sync after the first wipe the column, because
308
+ -- the unconditional list-phase write resolves nothing
309
+ -- and enrich only revisits _enriched_at IS NULL rows.
310
+ -- '{}' \u2192 caller authoritatively resolved zero participants
311
+ -- \u2192 clear, so a removed attendee really disappears.
312
+ -- {a,b} \u2192 replace with exactly these.
313
+ identity_keys = CASE
314
+ WHEN p_replace THEN COALESCE(p_identity_keys, r.identity_keys)
315
+ ELSE (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.identity_keys, '{}') || COALESCE(p_identity_keys, '{}'))))
316
+ END,
265
317
  sources = r.sources || COALESCE(p_sources, '{}'::jsonb),
266
318
  searchable_text = CASE
267
319
  WHEN p_replace THEN p_searchable_text
@@ -280,12 +332,13 @@ BEGIN
280
332
  result_action := 'updated';
281
333
  ELSE
282
334
  INSERT INTO mem_records (
283
- type, data, tags, keys, sources, searchable_text, content_hash, weight
335
+ type, data, tags, keys, identity_keys, sources, searchable_text, content_hash, weight
284
336
  ) VALUES (
285
337
  p_type,
286
338
  p_data,
287
339
  p_tags,
288
340
  p_keys,
341
+ p_identity_keys,
289
342
  COALESCE(p_sources, '{}'::jsonb),
290
343
  p_searchable_text,
291
344
  p_content_hash,
@@ -311,7 +364,8 @@ CREATE OR REPLACE FUNCTION mem_upsert_by_keys(
311
364
  p_weight INTEGER DEFAULT NULL,
312
365
  p_embedding TEXT DEFAULT NULL,
313
366
  p_embedding_model TEXT DEFAULT NULL,
314
- p_replace BOOLEAN DEFAULT FALSE
367
+ p_replace BOOLEAN DEFAULT FALSE,
368
+ p_identity_keys TEXT[] DEFAULT NULL
315
369
  ) RETURNS TABLE (id UUID, action TEXT) LANGUAGE plpgsql AS $$
316
370
  DECLARE
317
371
  existing_id UUID;
@@ -340,6 +394,13 @@ BEGIN
340
394
  ELSE (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.tags, '{}') || COALESCE(p_tags, '{}'))))
341
395
  END,
342
396
  keys = (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.keys, '{}') || COALESCE(p_keys, '{}')))),
397
+ -- identity_keys: replace on replace-sync, else union. Not part of
398
+ -- the overlap-merge. NULL keeps, '{}' clears, non-empty replaces \u2014
399
+ -- see the no-vector variant for why NULL must not clear.
400
+ identity_keys = CASE
401
+ WHEN p_replace THEN COALESCE(p_identity_keys, r.identity_keys)
402
+ ELSE (SELECT ARRAY(SELECT DISTINCT unnest FROM unnest(COALESCE(r.identity_keys, '{}') || COALESCE(p_identity_keys, '{}'))))
403
+ END,
343
404
  sources = r.sources || COALESCE(p_sources, '{}'::jsonb),
344
405
  searchable_text = CASE
345
406
  WHEN p_replace THEN p_searchable_text
@@ -361,13 +422,14 @@ BEGIN
361
422
  result_action := 'updated';
362
423
  ELSE
363
424
  INSERT INTO mem_records (
364
- type, data, tags, keys, sources, searchable_text, content_hash,
425
+ type, data, tags, keys, identity_keys, sources, searchable_text, content_hash,
365
426
  weight, embedding, embedded_at, embedding_model
366
427
  ) VALUES (
367
428
  p_type,
368
429
  p_data,
369
430
  p_tags,
370
431
  p_keys,
432
+ p_identity_keys,
371
433
  COALESCE(p_sources, '{}'::jsonb),
372
434
  p_searchable_text,
373
435
  p_content_hash,
@@ -505,6 +567,7 @@ function toRecord(row) {
505
567
  data: row.data,
506
568
  tags: row.tags ?? void 0,
507
569
  keys: row.keys ?? void 0,
570
+ identity_keys: row.identity_keys ?? void 0,
508
571
  sources: row.sources ?? {},
509
572
  searchable_text: row.searchable_text,
510
573
  embedded_at: row.embedded_at,
@@ -533,7 +596,17 @@ var CoreBackend = class {
533
596
  async ensureSchema() {
534
597
  const caps = this.caps;
535
598
  try {
536
- if (await this.getSchemaVersion() === SCHEMA_VERSION) return;
599
+ const probe = await this.client.query(
600
+ `SELECT (SELECT value FROM mem_meta WHERE key = 'version') AS version,
601
+ EXISTS (
602
+ SELECT 1 FROM pg_attribute
603
+ WHERE attrelid = to_regclass('mem_records')
604
+ AND attname = 'identity_keys'
605
+ AND NOT attisdropped
606
+ ) AS has_sentinel`
607
+ );
608
+ const probed = probe.rows[0];
609
+ if (probed?.version === SCHEMA_VERSION && probed.has_sentinel) return;
537
610
  } catch {
538
611
  }
539
612
  await this.client.transaction(async (tx) => {
@@ -565,19 +638,20 @@ var CoreBackend = class {
565
638
  async insert(row) {
566
639
  const embedding = vectorLiteral(row.embedding ?? null);
567
640
  const sql = this.caps.vectorSearch ? `INSERT INTO mem_records
568
- (type, data, tags, keys, sources, searchable_text, content_hash, weight,
641
+ (type, data, tags, keys, identity_keys, sources, searchable_text, content_hash, weight,
569
642
  embedding, embedded_at, embedding_model)
570
- VALUES ($1, $2::jsonb, $3::text[], $4::text[], $5::jsonb, $6, $7, $8,
571
- $9::vector, CASE WHEN $9 IS NOT NULL THEN NOW() ELSE NULL END, $10)
643
+ VALUES ($1, $2::jsonb, $3::text[], $4::text[], $5::text[], $6::jsonb, $7, $8, $9,
644
+ $10::vector, CASE WHEN $10 IS NOT NULL THEN NOW() ELSE NULL END, $11)
572
645
  RETURNING *` : `INSERT INTO mem_records
573
- (type, data, tags, keys, sources, searchable_text, content_hash, weight)
574
- VALUES ($1, $2::jsonb, $3::text[], $4::text[], $5::jsonb, $6, $7, $8)
646
+ (type, data, tags, keys, identity_keys, sources, searchable_text, content_hash, weight)
647
+ VALUES ($1, $2::jsonb, $3::text[], $4::text[], $5::text[], $6::jsonb, $7, $8, $9)
575
648
  RETURNING *`;
576
649
  const params = this.caps.vectorSearch ? [
577
650
  row.type,
578
651
  JSON.stringify(row.data),
579
652
  row.tags ?? null,
580
653
  row.keys ?? null,
654
+ row.identity_keys ?? null,
581
655
  JSON.stringify(row.sources ?? {}),
582
656
  row.searchable_text ?? null,
583
657
  row.content_hash ?? null,
@@ -589,6 +663,7 @@ var CoreBackend = class {
589
663
  JSON.stringify(row.data),
590
664
  row.tags ?? null,
591
665
  row.keys ?? null,
666
+ row.identity_keys ?? null,
592
667
  JSON.stringify(row.sources ?? {}),
593
668
  row.searchable_text ?? null,
594
669
  row.content_hash ?? null,
@@ -603,7 +678,7 @@ var CoreBackend = class {
603
678
  const res = await this.client.query(
604
679
  `SELECT id, action FROM mem_upsert_by_keys(
605
680
  $1::text, $2::jsonb, $3::text[], $4::text[], $5::jsonb, $6::text, $7::text,
606
- $8::integer, $9::text, $10::text, $11::boolean
681
+ $8::integer, $9::text, $10::text, $11::boolean, $12::text[]
607
682
  )`,
608
683
  [
609
684
  row.type,
@@ -616,7 +691,8 @@ var CoreBackend = class {
616
691
  row.weight ?? null,
617
692
  embedding,
618
693
  embeddingModel,
619
- opts.replace ?? false
694
+ opts.replace ?? false,
695
+ row.identity_keys ?? null
620
696
  ]
621
697
  );
622
698
  const { id, action } = res.rows[0];
@@ -999,6 +1075,48 @@ var CoreBackend = class {
999
1075
  );
1000
1076
  return res.rows[0] ? toRecord(res.rows[0]) : null;
1001
1077
  }
1078
+ async findByKeys(keys, opts = {}) {
1079
+ if (!keys.length) return [];
1080
+ const params = [keys];
1081
+ const where = [
1082
+ // Index-usable prefilter. The exact predicate below concatenates two
1083
+ // columns, which is an expression neither GIN index can answer — on its
1084
+ // own it plans as a Filter over a full scan, making
1085
+ // idx_records_identity_keys pure write cost. Overlap (`&&`) on the bare
1086
+ // columns IS index-usable and is a valid necessary condition here:
1087
+ // containment of a NON-EMPTY $1 implies every element is present, so at
1088
+ // least one element must overlap one of the two columns ($1 is guarded
1089
+ // non-empty by the early return above). The planner ORs the two bitmap
1090
+ // index scans, then the exact recheck drops rows that only partially
1091
+ // match.
1092
+ //
1093
+ // The columns MUST stay bare. Wrapping either in COALESCE turns it back
1094
+ // into an expression over a non-indexed value and the plan collapses to
1095
+ // a full scan again (measured) — do not "tidy" this into COALESCE for
1096
+ // symmetry with the line below. NULL && anything is NULL → not true,
1097
+ // which is exactly right: a record with no keys can't contain any.
1098
+ `(keys && $1::text[] OR identity_keys && $1::text[])`,
1099
+ `(COALESCE(keys, '{}'::text[]) || COALESCE(identity_keys, '{}'::text[])) @> $1::text[]`
1100
+ ];
1101
+ const status = opts.status ?? "active";
1102
+ if (status !== "all") {
1103
+ params.push(status);
1104
+ where.push(`status = $${params.length}`);
1105
+ }
1106
+ if (opts.type) {
1107
+ params.push(opts.type);
1108
+ where.push(`type = $${params.length}`);
1109
+ }
1110
+ params.push(Math.min(opts.limit ?? 2e3, 5e3));
1111
+ const res = await this.client.query(
1112
+ `SELECT * FROM mem_records
1113
+ WHERE ${where.join(" AND ")}
1114
+ ORDER BY type ASC, updated_at DESC NULLS LAST
1115
+ LIMIT $${params.length}`,
1116
+ params
1117
+ );
1118
+ return res.rows.map(toRecord);
1119
+ }
1002
1120
  async listSources(recordId) {
1003
1121
  const res = await this.client.query(
1004
1122
  `SELECT sources FROM mem_records WHERE id = $1`,
@@ -1293,6 +1411,9 @@ var LazyPostgresBackend = class {
1293
1411
  async findBySource(...a) {
1294
1412
  return (await this.ensure()).findBySource(...a);
1295
1413
  }
1414
+ async findByKeys(...a) {
1415
+ return (await this.ensure()).findByKeys(...a);
1416
+ }
1296
1417
  async listSources(...a) {
1297
1418
  return (await this.ensure()).listSources(...a);
1298
1419
  }
@@ -1648,6 +1769,9 @@ var LazyEmbeddedPostgresBackend = class {
1648
1769
  async findBySource(...a) {
1649
1770
  return (await this.ensure()).findBySource(...a);
1650
1771
  }
1772
+ async findByKeys(...a) {
1773
+ return (await this.ensure()).findByKeys(...a);
1774
+ }
1651
1775
  async listSources(...a) {
1652
1776
  return (await this.ensure()).listSources(...a);
1653
1777
  }
@@ -1832,8 +1956,8 @@ async function upsertRecord(input, opts = {}) {
1832
1956
  const { searchable_text, content_hash, embedding, embedding_model } = await prepareRecord(input, opts, "sync");
1833
1957
  const prepared = {
1834
1958
  ...input,
1835
- searchable_text: input.searchable_text ?? searchable_text,
1836
- content_hash: input.content_hash ?? content_hash,
1959
+ searchable_text: opts.preserveDerived ? void 0 : input.searchable_text ?? searchable_text,
1960
+ content_hash: opts.preserveDerived ? void 0 : input.content_hash ?? content_hash,
1837
1961
  embedding,
1838
1962
  embedding_model
1839
1963
  };