@hatk/hatk 0.0.1-alpha.84 → 0.0.1-alpha.86

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 (94) hide show
  1. package/dist/adapter.d.ts.map +1 -1
  2. package/dist/adapter.js +2 -0
  3. package/dist/cli.js +5 -1
  4. package/dist/config.d.ts +47 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +18 -1
  7. package/dist/database/db.d.ts +11 -0
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +356 -160
  10. package/dist/database/dialect.js +2 -2
  11. package/dist/database/fts.d.ts.map +1 -1
  12. package/dist/database/fts.js +6 -2
  13. package/dist/database/index.d.ts +1 -1
  14. package/dist/database/index.d.ts.map +1 -1
  15. package/dist/database/index.js +1 -1
  16. package/dist/database/schema.d.ts +2 -0
  17. package/dist/database/schema.d.ts.map +1 -1
  18. package/dist/database/schema.js +46 -4
  19. package/dist/dev-entry.d.ts.map +1 -1
  20. package/dist/dev-entry.js +2 -2
  21. package/dist/feeds.d.ts +14 -0
  22. package/dist/feeds.d.ts.map +1 -1
  23. package/dist/feeds.js +24 -13
  24. package/dist/hooks.d.ts +17 -0
  25. package/dist/hooks.d.ts.map +1 -1
  26. package/dist/hooks.js +26 -3
  27. package/dist/hydrate.d.ts +42 -0
  28. package/dist/hydrate.d.ts.map +1 -1
  29. package/dist/hydrate.js +18 -5
  30. package/dist/indexer.d.ts +4 -3
  31. package/dist/indexer.d.ts.map +1 -1
  32. package/dist/indexer.js +81 -64
  33. package/dist/lex-types.d.ts +2 -0
  34. package/dist/lex-types.d.ts.map +1 -1
  35. package/dist/main.js +26 -2
  36. package/dist/oauth/db.d.ts +9 -0
  37. package/dist/oauth/db.d.ts.map +1 -1
  38. package/dist/oauth/db.js +12 -0
  39. package/dist/opengraph.d.ts.map +1 -1
  40. package/dist/opengraph.js +16 -4
  41. package/dist/private-collections.d.ts +7 -2
  42. package/dist/private-collections.d.ts.map +1 -1
  43. package/dist/private-collections.js +9 -4
  44. package/dist/server.d.ts +8 -0
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +167 -15
  47. package/dist/spaces/blob.d.ts +51 -0
  48. package/dist/spaces/blob.d.ts.map +1 -0
  49. package/dist/spaces/blob.js +111 -0
  50. package/dist/spaces/credential.d.ts +62 -0
  51. package/dist/spaces/credential.d.ts.map +1 -0
  52. package/dist/spaces/credential.js +184 -0
  53. package/dist/spaces/engine.d.ts +97 -0
  54. package/dist/spaces/engine.d.ts.map +1 -0
  55. package/dist/spaces/engine.js +552 -0
  56. package/dist/spaces/guard.d.ts +41 -0
  57. package/dist/spaces/guard.d.ts.map +1 -0
  58. package/dist/spaces/guard.js +79 -0
  59. package/dist/spaces/identity.d.ts +37 -0
  60. package/dist/spaces/identity.d.ts.map +1 -0
  61. package/dist/spaces/identity.js +88 -0
  62. package/dist/spaces/index.d.ts +55 -0
  63. package/dist/spaces/index.d.ts.map +1 -0
  64. package/dist/spaces/index.js +133 -0
  65. package/dist/spaces/notify.d.ts +51 -0
  66. package/dist/spaces/notify.d.ts.map +1 -0
  67. package/dist/spaces/notify.js +121 -0
  68. package/dist/spaces/records.d.ts +37 -0
  69. package/dist/spaces/records.d.ts.map +1 -0
  70. package/dist/spaces/records.js +63 -0
  71. package/dist/spaces/store.d.ts +51 -0
  72. package/dist/spaces/store.d.ts.map +1 -0
  73. package/dist/spaces/store.js +99 -0
  74. package/dist/spaces/uri.d.ts +60 -0
  75. package/dist/spaces/uri.d.ts.map +1 -0
  76. package/dist/spaces/uri.js +97 -0
  77. package/dist/spaces/verify.d.ts +39 -0
  78. package/dist/spaces/verify.d.ts.map +1 -0
  79. package/dist/spaces/verify.js +87 -0
  80. package/dist/spaces/viewer.d.ts +51 -0
  81. package/dist/spaces/viewer.d.ts.map +1 -0
  82. package/dist/spaces/viewer.js +167 -0
  83. package/dist/spaces/visibility.d.ts +57 -0
  84. package/dist/spaces/visibility.d.ts.map +1 -0
  85. package/dist/spaces/visibility.js +81 -0
  86. package/dist/templates/feed.tpl +4 -0
  87. package/dist/templates/xrpc.tpl +13 -1
  88. package/dist/test.d.ts +2 -0
  89. package/dist/test.d.ts.map +1 -1
  90. package/dist/test.js +8 -5
  91. package/dist/xrpc.d.ts +19 -3
  92. package/dist/xrpc.d.ts.map +1 -1
  93. package/dist/xrpc.js +62 -32
  94. package/package.json +8 -4
@@ -3,6 +3,8 @@ import { getSearchColumns, stripStopWords, getSearchPort, updateFtsRecord, delet
3
3
  import { emit, timer } from "../logger.js";
4
4
  import { OAUTH_DDL } from "../oauth/db.js";
5
5
  import { getDialect } from "./dialect.js";
6
+ import { collectionFromRecordUri, spaceFromUri } from "../spaces/uri.js";
7
+ import { spaceFilterSql } from "../spaces/visibility.js";
6
8
  let port;
7
9
  let dialect;
8
10
  const schemas = new Map();
@@ -80,6 +82,33 @@ export async function initDatabase(adapter, dbPath, tableSchemas, ddlStatements)
80
82
  key TEXT PRIMARY KEY,
81
83
  value TEXT NOT NULL
82
84
  )`);
85
+ // Permissioned-space sync state. `_space_watch` is one row per space this
86
+ // instance follows; `_space_repos` is the writer set, which is the sync
87
+ // boundary — a space is the aggregation of one repo per writer, each on that
88
+ // writer's own PDS, so progress is tracked per (space, writer) rather than
89
+ // per space.
90
+ //
91
+ // `reader_did` is the account whose session last minted a credential for the
92
+ // space. hatk is not itself a member of anything: it reads as somebody who
93
+ // is, so which somebody worked last is worth remembering.
94
+ await run(`CREATE TABLE IF NOT EXISTS _space_watch (
95
+ space TEXT PRIMARY KEY,
96
+ authority TEXT NOT NULL,
97
+ space_type TEXT NOT NULL,
98
+ reader_did TEXT,
99
+ registered_until TEXT,
100
+ last_error TEXT,
101
+ updated_at TEXT NOT NULL
102
+ )`);
103
+ await run(`CREATE TABLE IF NOT EXISTS _space_repos (
104
+ space TEXT NOT NULL,
105
+ did TEXT NOT NULL,
106
+ pds TEXT,
107
+ rev TEXT,
108
+ synced_at TEXT,
109
+ PRIMARY KEY (space, did)
110
+ )`);
111
+ await run(`CREATE INDEX IF NOT EXISTS idx_space_repos_space ON _space_repos(space)`);
83
112
  // Labels table (atproto-compatible)
84
113
  if (dialect.supportsSequences) {
85
114
  await run(`CREATE SEQUENCE IF NOT EXISTS _labels_seq START 1`);
@@ -255,6 +284,7 @@ export async function migrateSchema(tableSchemas) {
255
284
  expectedCols.set('uri', 'TEXT');
256
285
  expectedCols.set('cid', 'TEXT');
257
286
  expectedCols.set('did', 'TEXT');
287
+ expectedCols.set('space', 'TEXT');
258
288
  expectedCols.set('indexed_at', normalizeType(dialect.timestampType));
259
289
  for (const col of schema.columns) {
260
290
  expectedCols.set(col.name, normalizeType(col.sqlType));
@@ -328,24 +358,37 @@ async function applyMigrationChanges(changes) {
328
358
  for (const change of changes) {
329
359
  const quotedTable = `"${change.table}"`;
330
360
  const quotedColumn = `"${change.column}"`;
361
+ // Index naming mirrors generateCreateTableSQL and the 'add' branch below:
362
+ // the table name with dots replaced by underscores, then the column name.
363
+ const indexName = `idx_${change.table.replace(/\./g, '_')}_${change.column}`;
364
+ // SQLite refuses to drop a column an index still references ("error in
365
+ // index ... after drop column"), so any DROP COLUMN has to clear it first.
366
+ const dropColumn = async () => {
367
+ await run(`DROP INDEX IF EXISTS "${indexName}"`);
368
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
369
+ };
331
370
  try {
332
371
  switch (change.action) {
333
372
  case 'add': {
334
373
  await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
335
374
  emit('migration', 'add_column', { table: change.table, column: change.column, type: change.type });
336
375
  const schema = schemas.get(change.table);
337
- if (schema?.refColumns.includes(change.column)) {
338
- const prefix = change.table.replace(/\./g, '_');
339
- await run(`CREATE INDEX IF NOT EXISTS idx_${prefix}_${change.column} ON ${quotedTable}(${quotedColumn})`);
376
+ // `space` is indexed like a ref column but is not one: it is in the
377
+ // base row rather than the lexicon, so refColumns never names it, and
378
+ // an existing table that gains it would otherwise scan on every read.
379
+ if (change.column === 'space' || schema?.refColumns.includes(change.column)) {
380
+ await run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${quotedTable}(${quotedColumn})`);
340
381
  }
341
382
  break;
342
383
  }
343
384
  case 'drop':
344
- await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
385
+ await dropColumn();
345
386
  emit('migration', 'drop_column', { table: change.table, column: change.column });
346
387
  break;
347
388
  case 'retype':
348
- await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
389
+ // A ref column is always TEXT, so a type change means it stopped
390
+ // being one — the index goes with the old column and is not rebuilt.
391
+ await dropColumn();
349
392
  await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
350
393
  emit('migration', 'retype_column', { table: change.table, column: change.column, type: change.type });
351
394
  break;
@@ -502,7 +545,8 @@ export async function getRecentRecords(collection, limit) {
502
545
  const schema = schemas.get(collection);
503
546
  if (!schema)
504
547
  return [];
505
- const rows = await all(`SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at ORDER BY t.indexed_at DESC LIMIT $1`, [limit]);
548
+ const gate = spaceFilterSql('t', 2);
549
+ const rows = await all(`SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at AND ${gate.sql} ORDER BY t.indexed_at DESC LIMIT $1`, [limit, ...gate.params]);
506
550
  return rows;
507
551
  }
508
552
  export async function getSchemaDump() {
@@ -544,10 +588,13 @@ export function buildInsertOp(collection, uri, cid, authorDid, record) {
544
588
  const schema = schemas.get(collection);
545
589
  if (!schema)
546
590
  throw new Error(`Unknown collection: ${collection}`);
547
- const colNames = ['uri', 'cid', 'did', 'indexed_at'];
548
- const placeholders = ['$1', '$2', '$3', '$4'];
549
- const values = [uri, cid, authorDid, new Date().toISOString()];
550
- let paramIdx = 5;
591
+ // Derived from the URI rather than passed in. A space record's URI already
592
+ // names its space, so reading it here means no write path can land a space
593
+ // row with the column left NULL — which would publish it to every reader.
594
+ const colNames = ['uri', 'cid', 'did', 'space', 'indexed_at'];
595
+ const placeholders = ['$1', '$2', '$3', '$4', '$5'];
596
+ const values = [uri, cid, authorDid, spaceFromUri(uri) ?? null, new Date().toISOString()];
597
+ let paramIdx = 6;
551
598
  for (const col of schema.columns) {
552
599
  let rawValue = record[col.originalName];
553
600
  // Handle strongRef expansion: subject_uri reads record.subject.uri, subject__cid reads record.subject.cid
@@ -698,6 +745,57 @@ export async function deleteRecord(collection, uri) {
698
745
  }
699
746
  await run(`DELETE FROM ${schema.tableName} WHERE uri = $1`, [uri]);
700
747
  }
748
+ /**
749
+ * Remove every row a writer holds in one space.
750
+ *
751
+ * Scoped by `(space, did)` rather than by DID alone: the same account can hold
752
+ * public repo records in the same collections, and a full re-read of one space
753
+ * must not take those with it. Child and union rows are cleared by parent URI
754
+ * for the same reason — `parent_did` does not say which space a row came from.
755
+ */
756
+ export async function purgeSpaceRecords(space, did, collections) {
757
+ const CHUNK = 500;
758
+ for (const collection of collections) {
759
+ const schema = schemas.get(collection);
760
+ if (!schema)
761
+ continue;
762
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE space = $1 AND did = $2`, [
763
+ space,
764
+ did,
765
+ ]);
766
+ const uris = rows.map((r) => r.uri);
767
+ if (uris.length === 0)
768
+ continue;
769
+ for (const uri of uris)
770
+ await deleteFtsRecord(collection, uri);
771
+ for (let i = 0; i < uris.length; i += CHUNK) {
772
+ const batch = uris.slice(i, i + CHUNK);
773
+ const placeholders = batch.map((_, j) => `$${j + 1}`).join(',');
774
+ for (const child of schema.children) {
775
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${placeholders})`, batch);
776
+ }
777
+ for (const union of schema.unions) {
778
+ for (const branch of union.branches) {
779
+ await run(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${placeholders})`, batch);
780
+ }
781
+ }
782
+ }
783
+ await run(`DELETE FROM ${schema.tableName} WHERE space = $1 AND did = $2`, [space, did]);
784
+ }
785
+ }
786
+ /** Every writer holding rows for a space, as the index currently has it. */
787
+ export async function listSpaceRecordDids(space, collections) {
788
+ const dids = new Set();
789
+ for (const collection of collections) {
790
+ const schema = schemas.get(collection);
791
+ if (!schema)
792
+ continue;
793
+ const rows = await all(`SELECT DISTINCT did FROM ${schema.tableName} WHERE space = $1`, [space]);
794
+ for (const row of rows)
795
+ dids.add(row.did);
796
+ }
797
+ return [...dids];
798
+ }
701
799
  export async function insertLabels(labels) {
702
800
  if (labels.length === 0)
703
801
  return;
@@ -737,6 +835,12 @@ export async function queryLabelsForUris(uris) {
737
835
  }
738
836
  return result;
739
837
  }
838
+ /** Per-process token + counter, so concurrent calls never share a staging table. */
839
+ const STAGING_TOKEN = Math.floor(Math.random() * 0xffffff).toString(36);
840
+ let stagingCounter = 0;
841
+ function nextStagingId() {
842
+ return `${STAGING_TOKEN}_${(stagingCounter++).toString(36)}`;
843
+ }
740
844
  export async function bulkInsertRecords(records) {
741
845
  if (records.length === 0)
742
846
  return 0;
@@ -752,171 +856,192 @@ export async function bulkInsertRecords(records) {
752
856
  const schema = schemas.get(collection);
753
857
  if (!schema)
754
858
  continue;
755
- const stagingTable = `_staging_${collection.replace(/\./g, '_')}`;
756
- const allCols = ['uri', 'cid', 'did', 'indexed_at', ...schema.columns.map((c) => q(c.name))];
757
- const colDefs = [
758
- 'uri TEXT',
759
- 'cid TEXT',
760
- 'did TEXT',
761
- 'indexed_at TEXT',
762
- ...schema.columns.map((c) => {
763
- const t = c.sqlType;
764
- // Use TEXT for timestamp columns in staging (will cast on merge)
765
- return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
766
- }),
767
- ];
768
- await port.execute(`DROP TABLE IF EXISTS ${stagingTable}`, []);
769
- await port.execute(`CREATE TABLE ${stagingTable} (${colDefs.join(', ')})`, []);
770
- const inserter = await port.createBulkInserter(stagingTable, allCols);
771
- const now = new Date().toISOString();
772
- for (const rec of recs) {
773
- try {
774
- const values = [rec.uri, rec.cid, rec.did, now];
775
- for (const col of schema.columns) {
776
- values.push(resolveColumnValue(col, rec.record));
777
- }
778
- inserter.append(values);
779
- inserted++;
780
- }
781
- catch {
782
- // Skip bad records
783
- }
784
- }
785
- await inserter.close();
786
- // Merge into target, filtering rows that would violate NOT NULL
787
- const selectCols = allCols.map((name) => {
788
- const col = schema.columns.find((c) => q(c.name) === name);
789
- if (name === 'indexed_at' || (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ'))) {
790
- return `${dialect.tryCastTimestamp(name)} AS ${name}`;
791
- }
859
+ // Staging tables are named per call, not per collection: runBackfill
860
+ // imports repos in parallel, so two workers on the same collection would
861
+ // otherwise drop and recreate each other's staging table mid-insert.
862
+ const stagingTables = [];
863
+ const stagingName = (suffix) => {
864
+ const name = `_staging_${collection.replace(/\./g, '_')}${suffix}_${nextStagingId()}`;
865
+ stagingTables.push(name);
792
866
  return name;
793
- });
794
- const notNullChecks = ['uri IS NOT NULL', 'did IS NOT NULL'];
795
- for (const col of schema.columns) {
796
- if (col.notNull) {
797
- if (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ') {
798
- notNullChecks.push(`${dialect.tryCastTimestamp(q(col.name))} IS NOT NULL`);
799
- }
800
- else {
801
- notNullChecks.push(`${q(col.name)} IS NOT NULL`);
802
- }
803
- }
804
- }
805
- const whereClause = notNullChecks.length ? ` WHERE ${notNullChecks.join(' AND ')}` : '';
806
- await port.execute(`INSERT OR REPLACE INTO ${schema.tableName} (${allCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM ${stagingTable}${whereClause}`, []);
807
- await port.execute(`DROP TABLE ${stagingTable}`, []);
808
- // Populate child tables
809
- for (const child of schema.children) {
810
- const childStagingTable = `_staging_${collection.replace(/\./g, '_')}__${child.fieldName}`;
811
- const childColDefs = [
812
- 'parent_uri TEXT',
813
- 'parent_did TEXT',
814
- ...child.columns.map((c) => {
867
+ };
868
+ try {
869
+ const stagingTable = stagingName('');
870
+ const allCols = ['uri', 'cid', 'did', 'space', 'indexed_at', ...schema.columns.map((c) => q(c.name))];
871
+ const colDefs = [
872
+ 'uri TEXT',
873
+ 'cid TEXT',
874
+ 'did TEXT',
875
+ 'space TEXT',
876
+ 'indexed_at TEXT',
877
+ ...schema.columns.map((c) => {
815
878
  const t = c.sqlType;
879
+ // Use TEXT for timestamp columns in staging (will cast on merge)
816
880
  return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
817
881
  }),
818
882
  ];
819
- const childAllCols = ['parent_uri', 'parent_did', ...child.columns.map((c) => q(c.name))];
820
- await port.execute(`DROP TABLE IF EXISTS ${childStagingTable}`, []);
821
- await port.execute(`CREATE TABLE ${childStagingTable} (${childColDefs.join(', ')})`, []);
822
- const childInserter = await port.createBulkInserter(childStagingTable, childAllCols);
883
+ await port.execute(`DROP TABLE IF EXISTS ${stagingTable}`, []);
884
+ await port.execute(`CREATE TABLE ${stagingTable} (${colDefs.join(', ')})`, []);
885
+ const inserter = await port.createBulkInserter(stagingTable, allCols);
886
+ const now = new Date().toISOString();
823
887
  for (const rec of recs) {
824
- const items = rec.record[child.fieldName];
825
- if (!Array.isArray(items))
826
- continue;
827
- for (const item of items) {
828
- try {
829
- const values = [rec.uri, rec.did];
830
- for (const col of child.columns) {
831
- values.push(resolveRawColumnValue(col, item));
832
- }
833
- childInserter.append(values);
834
- }
835
- catch {
836
- // Skip bad items
888
+ try {
889
+ const values = [rec.uri, rec.cid, rec.did, spaceFromUri(rec.uri) ?? null, now];
890
+ for (const col of schema.columns) {
891
+ values.push(resolveColumnValue(col, rec.record));
837
892
  }
893
+ inserter.append(values);
894
+ inserted++;
895
+ }
896
+ catch {
897
+ // Skip bad records
838
898
  }
839
899
  }
840
- await childInserter.close();
841
- // Delete existing child rows for these URIs, then merge staging
842
- const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
843
- await port.execute(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
844
- const childSelectCols = childAllCols.map((name) => {
845
- const col = child.columns.find((c) => q(c.name) === name);
846
- if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
900
+ await inserter.close();
901
+ // Merge into target, filtering rows that would violate NOT NULL
902
+ const selectCols = allCols.map((name) => {
903
+ const col = schema.columns.find((c) => q(c.name) === name);
904
+ if (name === 'indexed_at' || (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ'))) {
847
905
  return `${dialect.tryCastTimestamp(name)} AS ${name}`;
848
906
  }
849
907
  return name;
850
908
  });
851
- await port.execute(`INSERT INTO ${child.tableName} (${childAllCols.join(', ')}) SELECT ${childSelectCols.join(', ')} FROM ${childStagingTable} WHERE parent_uri IS NOT NULL`, []);
852
- await port.execute(`DROP TABLE ${childStagingTable}`, []);
853
- }
854
- // Populate union branch tables
855
- for (const union of schema.unions) {
856
- for (const branch of union.branches) {
857
- const branchStagingTable = `_staging_${collection.replace(/\./g, '_')}__${toSnakeCase(union.fieldName)}_${branch.branchName}`;
858
- const branchColDefs = [
909
+ const notNullChecks = ['uri IS NOT NULL', 'did IS NOT NULL'];
910
+ for (const col of schema.columns) {
911
+ if (col.notNull) {
912
+ if (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ') {
913
+ notNullChecks.push(`${dialect.tryCastTimestamp(q(col.name))} IS NOT NULL`);
914
+ }
915
+ else {
916
+ notNullChecks.push(`${q(col.name)} IS NOT NULL`);
917
+ }
918
+ }
919
+ }
920
+ const whereClause = notNullChecks.length ? ` WHERE ${notNullChecks.join(' AND ')}` : '';
921
+ await port.execute(`INSERT OR REPLACE INTO ${schema.tableName} (${allCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM ${stagingTable}${whereClause}`, []);
922
+ await port.execute(`DROP TABLE ${stagingTable}`, []);
923
+ // Populate child tables
924
+ for (const child of schema.children) {
925
+ const childStagingTable = stagingName(`__${child.fieldName}`);
926
+ const childColDefs = [
859
927
  'parent_uri TEXT',
860
928
  'parent_did TEXT',
861
- ...branch.columns.map((c) => {
929
+ ...child.columns.map((c) => {
862
930
  const t = c.sqlType;
863
931
  return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
864
932
  }),
865
933
  ];
866
- const branchAllCols = ['parent_uri', 'parent_did', ...branch.columns.map((c) => q(c.name))];
867
- await port.execute(`DROP TABLE IF EXISTS ${branchStagingTable}`, []);
868
- await port.execute(`CREATE TABLE ${branchStagingTable} (${branchColDefs.join(', ')})`, []);
869
- const branchInserter = await port.createBulkInserter(branchStagingTable, branchAllCols);
934
+ const childAllCols = ['parent_uri', 'parent_did', ...child.columns.map((c) => q(c.name))];
935
+ await port.execute(`DROP TABLE IF EXISTS ${childStagingTable}`, []);
936
+ await port.execute(`CREATE TABLE ${childStagingTable} (${childColDefs.join(', ')})`, []);
937
+ const childInserter = await port.createBulkInserter(childStagingTable, childAllCols);
870
938
  for (const rec of recs) {
871
- const unionValue = rec.record[union.fieldName];
872
- if (!unionValue || typeof unionValue !== 'object')
873
- continue;
874
- if (unionValue.$type !== branch.type)
939
+ const items = rec.record[child.fieldName];
940
+ if (!Array.isArray(items))
875
941
  continue;
876
- if (branch.isArray && branch.arrayField) {
877
- const items = resolveBranchData(unionValue, branch)[branch.arrayField];
878
- if (!Array.isArray(items))
879
- continue;
880
- for (const item of items) {
881
- try {
882
- const values = [rec.uri, rec.did];
883
- for (const col of branch.columns) {
884
- values.push(resolveRawColumnValue(col, item));
885
- }
886
- branchInserter.append(values);
887
- }
888
- catch {
889
- // Skip bad items
890
- }
891
- }
892
- }
893
- else {
942
+ for (const item of items) {
894
943
  try {
895
- const branchData = resolveBranchData(unionValue, branch);
896
944
  const values = [rec.uri, rec.did];
897
- for (const col of branch.columns) {
898
- values.push(resolveRawColumnValue(col, branchData));
945
+ for (const col of child.columns) {
946
+ values.push(resolveRawColumnValue(col, item));
899
947
  }
900
- branchInserter.append(values);
948
+ childInserter.append(values);
901
949
  }
902
950
  catch {
903
- // Skip bad records
951
+ // Skip bad items
904
952
  }
905
953
  }
906
954
  }
907
- await branchInserter.close();
908
- // Delete existing branch rows for these URIs, then merge staging
955
+ await childInserter.close();
956
+ // Delete existing child rows for these URIs, then merge staging
909
957
  const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
910
- await port.execute(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
911
- const branchSelectCols = branchAllCols.map((name) => {
912
- const col = branch.columns.find((c) => q(c.name) === name);
958
+ await port.execute(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
959
+ const childSelectCols = childAllCols.map((name) => {
960
+ const col = child.columns.find((c) => q(c.name) === name);
913
961
  if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
914
962
  return `${dialect.tryCastTimestamp(name)} AS ${name}`;
915
963
  }
916
964
  return name;
917
965
  });
918
- await port.execute(`INSERT INTO ${branch.tableName} (${branchAllCols.join(', ')}) SELECT ${branchSelectCols.join(', ')} FROM ${branchStagingTable} WHERE parent_uri IS NOT NULL`, []);
919
- await port.execute(`DROP TABLE ${branchStagingTable}`, []);
966
+ await port.execute(`INSERT INTO ${child.tableName} (${childAllCols.join(', ')}) SELECT ${childSelectCols.join(', ')} FROM ${childStagingTable} WHERE parent_uri IS NOT NULL`, []);
967
+ await port.execute(`DROP TABLE ${childStagingTable}`, []);
968
+ }
969
+ // Populate union branch tables
970
+ for (const union of schema.unions) {
971
+ for (const branch of union.branches) {
972
+ const branchStagingTable = stagingName(`__${toSnakeCase(union.fieldName)}_${branch.branchName}`);
973
+ const branchColDefs = [
974
+ 'parent_uri TEXT',
975
+ 'parent_did TEXT',
976
+ ...branch.columns.map((c) => {
977
+ const t = c.sqlType;
978
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
979
+ }),
980
+ ];
981
+ const branchAllCols = ['parent_uri', 'parent_did', ...branch.columns.map((c) => q(c.name))];
982
+ await port.execute(`DROP TABLE IF EXISTS ${branchStagingTable}`, []);
983
+ await port.execute(`CREATE TABLE ${branchStagingTable} (${branchColDefs.join(', ')})`, []);
984
+ const branchInserter = await port.createBulkInserter(branchStagingTable, branchAllCols);
985
+ for (const rec of recs) {
986
+ const unionValue = rec.record[union.fieldName];
987
+ if (!unionValue || typeof unionValue !== 'object')
988
+ continue;
989
+ if (unionValue.$type !== branch.type)
990
+ continue;
991
+ if (branch.isArray && branch.arrayField) {
992
+ const items = resolveBranchData(unionValue, branch)[branch.arrayField];
993
+ if (!Array.isArray(items))
994
+ continue;
995
+ for (const item of items) {
996
+ try {
997
+ const values = [rec.uri, rec.did];
998
+ for (const col of branch.columns) {
999
+ values.push(resolveRawColumnValue(col, item));
1000
+ }
1001
+ branchInserter.append(values);
1002
+ }
1003
+ catch {
1004
+ // Skip bad items
1005
+ }
1006
+ }
1007
+ }
1008
+ else {
1009
+ try {
1010
+ const branchData = resolveBranchData(unionValue, branch);
1011
+ const values = [rec.uri, rec.did];
1012
+ for (const col of branch.columns) {
1013
+ values.push(resolveRawColumnValue(col, branchData));
1014
+ }
1015
+ branchInserter.append(values);
1016
+ }
1017
+ catch {
1018
+ // Skip bad records
1019
+ }
1020
+ }
1021
+ }
1022
+ await branchInserter.close();
1023
+ // Delete existing branch rows for these URIs, then merge staging
1024
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
1025
+ await port.execute(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
1026
+ const branchSelectCols = branchAllCols.map((name) => {
1027
+ const col = branch.columns.find((c) => q(c.name) === name);
1028
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
1029
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
1030
+ }
1031
+ return name;
1032
+ });
1033
+ await port.execute(`INSERT INTO ${branch.tableName} (${branchAllCols.join(', ')}) SELECT ${branchSelectCols.join(', ')} FROM ${branchStagingTable} WHERE parent_uri IS NOT NULL`, []);
1034
+ await port.execute(`DROP TABLE ${branchStagingTable}`, []);
1035
+ }
1036
+ }
1037
+ }
1038
+ finally {
1039
+ // Drop on the error path too, so a failed batch leaves nothing behind.
1040
+ for (const name of stagingTables) {
1041
+ try {
1042
+ await port.execute(`DROP TABLE IF EXISTS ${name}`, []);
1043
+ }
1044
+ catch { }
920
1045
  }
921
1046
  }
922
1047
  }
@@ -961,6 +1086,16 @@ export async function queryRecords(collection, opts = {}) {
961
1086
  if (!schema)
962
1087
  throw new Error(`Unknown collection: ${collection}`);
963
1088
  const { limit = 20, cursor, filters, sort = 'indexed_at', order = 'desc' } = opts;
1089
+ // `order` is interpolated into the ORDER BY clause, not bound as a
1090
+ // parameter, so it has to be an exact match rather than a trusted caller
1091
+ // value — anything else is SQL reaching the engine verbatim.
1092
+ const direction = String(order).toUpperCase();
1093
+ if (direction !== 'ASC' && direction !== 'DESC')
1094
+ throw new Error(`Invalid sort order: ${order}`);
1095
+ // SQLite (and DuckDB) read a negative LIMIT as unbounded, so an unchecked
1096
+ // -1 returns the entire table.
1097
+ if (!Number.isInteger(limit) || limit < 1)
1098
+ throw new Error(`Invalid limit: ${limit}`);
964
1099
  // Validate sort field exists
965
1100
  const sortCol = sort === 'indexed_at' ? 'indexed_at' : schema.columns.find((c) => c.originalName === sort || c.name === sort);
966
1101
  const sortName = typeof sortCol === 'string' ? sortCol : sortCol?.name;
@@ -973,7 +1108,7 @@ export async function queryRecords(collection, opts = {}) {
973
1108
  if (cursor) {
974
1109
  const parsed = unpackCursor(cursor);
975
1110
  if (parsed) {
976
- const op = order === 'desc' ? '<' : '>';
1111
+ const op = direction === 'DESC' ? '<' : '>';
977
1112
  const pSort1 = `$${paramIdx++}`;
978
1113
  const pSort2 = `$${paramIdx++}`;
979
1114
  const pCid = `$${paramIdx++}`;
@@ -993,10 +1128,15 @@ export async function queryRecords(collection, opts = {}) {
993
1128
  params.push(value);
994
1129
  }
995
1130
  }
1131
+ // Space rows are served only to a read scoped to that space; see spaces/visibility.ts.
1132
+ const gate = spaceFilterSql('t', paramIdx);
1133
+ conditions.push(gate.sql);
1134
+ params.push(...gate.params);
1135
+ paramIdx = gate.nextIdx;
996
1136
  let sql = `SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE (r.status IS NULL OR r.status != 'takendown')`;
997
1137
  if (conditions.length)
998
1138
  sql += ' AND ' + conditions.join(' AND ');
999
- sql += ` ORDER BY t.${sortName} ${order.toUpperCase()}, t.cid ${order.toUpperCase()} LIMIT $${paramIdx++}`;
1139
+ sql += ` ORDER BY t.${sortName} ${direction}, t.cid ${direction} LIMIT $${paramIdx++}`;
1000
1140
  params.push(limit + 1); // fetch one extra for cursor
1001
1141
  const rows = await all(sql, params);
1002
1142
  const hasMore = rows.length > limit;
@@ -1038,7 +1178,8 @@ export async function queryRecords(collection, opts = {}) {
1038
1178
  }
1039
1179
  export async function getRecordByUri(uri) {
1040
1180
  for (const [_collection, schema] of schemas) {
1041
- const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri = $1 AND (r.status IS NULL OR r.status != 'takendown')`, [uri]);
1181
+ const gate = spaceFilterSql('t', 2);
1182
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri = $1 AND ${gate.sql} AND (r.status IS NULL OR r.status != 'takendown')`, [uri, ...gate.params]);
1042
1183
  if (rows.length > 0) {
1043
1184
  const row = rows[0];
1044
1185
  if (schema.children.length > 0) {
@@ -1075,7 +1216,8 @@ export async function getRecordsByUris(collection, uris) {
1075
1216
  if (!schema)
1076
1217
  return [];
1077
1218
  const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
1078
- const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri IN (${placeholders}) AND (r.status IS NULL OR r.status != 'takendown')`, uris);
1219
+ const gate = spaceFilterSql('t', uris.length + 1);
1220
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri IN (${placeholders}) AND ${gate.sql} AND (r.status IS NULL OR r.status != 'takendown')`, [...uris, ...gate.params]);
1079
1221
  // Batch-fetch child rows for all URIs
1080
1222
  const childData = new Map();
1081
1223
  for (const child of schema.children) {
@@ -1161,10 +1303,12 @@ export async function searchRecords(collection, query, opts = {}) {
1161
1303
  const scoreMap = new Map(hits.map((h) => [h.uri, h.score]));
1162
1304
  // Fetch full records for matched URIs
1163
1305
  const placeholders = uriList.map((_, i) => `$${i + 1}`).join(', ');
1306
+ const gate = spaceFilterSql('m', uriList.length + 1);
1164
1307
  const rows = await all(`SELECT m.* FROM ${schema.tableName} m
1165
1308
  LEFT JOIN _repos r ON m.did = r.did
1166
1309
  WHERE m.uri IN (${placeholders})
1167
- AND (r.status IS NULL OR r.status != 'takendown')`, uriList);
1310
+ AND ${gate.sql}
1311
+ AND (r.status IS NULL OR r.status != 'takendown')`, [...uriList, ...gate.params]);
1168
1312
  // Re-attach scores and sort
1169
1313
  bm25Results = rows
1170
1314
  .map((r) => ({ ...r, score: scoreMap.get(r.uri) ?? 0 }))
@@ -1199,13 +1343,19 @@ export async function searchRecords(collection, query, opts = {}) {
1199
1343
  }
1200
1344
  }
1201
1345
  const greatestExpr = dialect.greatest(simExprs);
1346
+ const fuzzyGate = spaceFilterSql('t', 3);
1202
1347
  const fuzzySQL = `SELECT t.*, ${greatestExpr} AS fuzzy_score
1203
1348
  FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did
1204
1349
  WHERE ${greatestExpr} >= 0.8
1350
+ AND ${fuzzyGate.sql}
1205
1351
  ORDER BY fuzzy_score DESC
1206
1352
  LIMIT $2`;
1207
1353
  try {
1208
- const fuzzyRows = await all(fuzzySQL, [query, remaining + existingUris.size]);
1354
+ const fuzzyRows = await all(fuzzySQL, [
1355
+ query,
1356
+ remaining + existingUris.size,
1357
+ ...fuzzyGate.params,
1358
+ ]);
1209
1359
  phasesUsed.push('fuzzy');
1210
1360
  for (const row of fuzzyRows) {
1211
1361
  if (bm25Results.length >= limit)
@@ -1256,9 +1406,8 @@ export async function countByField(collection, field, value) {
1256
1406
  const schema = schemas.get(collection);
1257
1407
  if (!schema)
1258
1408
  return 0;
1259
- const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, [
1260
- value,
1261
- ]);
1409
+ const gate = spaceFilterSql('', 2);
1410
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1 AND ${gate.sql}`, [value, ...gate.params]);
1262
1411
  return Number(rows[0]?.count || 0);
1263
1412
  }
1264
1413
  export async function countByFieldBatch(collection, field, values) {
@@ -1268,7 +1417,8 @@ export async function countByFieldBatch(collection, field, values) {
1268
1417
  if (!schema)
1269
1418
  return new Map();
1270
1419
  const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1271
- const rows = await all(`SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) GROUP BY ${field}`, values);
1420
+ const gate = spaceFilterSql('', values.length + 1);
1421
+ const rows = await all(`SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) AND ${gate.sql} GROUP BY ${field}`, [...values, ...gate.params]);
1272
1422
  const result = new Map();
1273
1423
  for (const row of rows) {
1274
1424
  result.set(row[field], Number(row.count));
@@ -1279,7 +1429,11 @@ export async function findByField(collection, field, value) {
1279
1429
  const schema = schemas.get(collection);
1280
1430
  if (!schema)
1281
1431
  return null;
1282
- const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 LIMIT 1`, [value]);
1432
+ const gate = spaceFilterSql('', 2);
1433
+ const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 AND ${gate.sql} LIMIT 1`, [
1434
+ value,
1435
+ ...gate.params,
1436
+ ]);
1283
1437
  return rows[0] || null;
1284
1438
  }
1285
1439
  export async function findByFieldBatch(collection, field, values) {
@@ -1289,7 +1443,8 @@ export async function findByFieldBatch(collection, field, values) {
1289
1443
  if (!schema)
1290
1444
  return new Map();
1291
1445
  const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1292
- const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.${field} IN (${placeholders})`, values);
1446
+ const gate = spaceFilterSql('t', values.length + 1);
1447
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.${field} IN (${placeholders}) AND ${gate.sql}`, [...values, ...gate.params]);
1293
1448
  // Attach child data if this collection has decomposed arrays
1294
1449
  if (schema.children.length > 0 && rows.length > 0) {
1295
1450
  const uris = rows.map((r) => r.uri);
@@ -1329,10 +1484,11 @@ export async function findUriByFields(collection, conditions) {
1329
1484
  return null;
1330
1485
  const where = conditions.map((c, i) => `${c.field} = $${i + 1}`).join(' AND ');
1331
1486
  const params = conditions.map((c) => c.value);
1332
- const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, params);
1487
+ const gate = spaceFilterSql('', conditions.length + 1);
1488
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} AND ${gate.sql} LIMIT 1`, [...params, ...gate.params]);
1333
1489
  return rows[0]?.uri || null;
1334
1490
  }
1335
- const ENVELOPE_KEYS = new Set(['uri', 'cid', 'did', 'handle', 'indexed_at']);
1491
+ const ENVELOPE_KEYS = new Set(['uri', 'cid', 'did', 'space', 'handle', 'indexed_at']);
1336
1492
  const INTERNAL_KEYS = new Set(['__childData', '__unionData']);
1337
1493
  export function normalizeValue(v) {
1338
1494
  if (v && typeof v === 'object' && 'micros' in v)
@@ -1355,11 +1511,29 @@ export async function getChildRows(childTableName, parentUris) {
1355
1511
  }
1356
1512
  return result;
1357
1513
  }
1514
+ /**
1515
+ * Normalize one stored column back to its record value. JSON columns are
1516
+ * written with JSON.stringify on insert, so they have to be parsed on the way
1517
+ * out — on child and union-branch rows just as much as on the main table.
1518
+ */
1519
+ function decodeColumn(col, raw) {
1520
+ const val = normalizeValue(raw);
1521
+ if (!col.isJson || typeof val !== 'string')
1522
+ return val;
1523
+ try {
1524
+ return JSON.parse(val);
1525
+ }
1526
+ catch {
1527
+ return val;
1528
+ }
1529
+ }
1358
1530
  export function reshapeRow(row, childData, unionData) {
1359
1531
  if (!row)
1360
1532
  return null;
1361
- // Derive collection from URI (at://did/collection/rkey)
1362
- const collection = row.uri?.split('/')?.[3];
1533
+ // A space record's URI puts the collection in a different segment than a
1534
+ // repo record's. Reading it positionally found no schema for space rows,
1535
+ // which left their columns snake_cased and their JSON columns unparsed.
1536
+ const collection = row.uri ? collectionFromRecordUri(row.uri) : undefined;
1363
1537
  const schema = collection ? schemas.get(collection) : null;
1364
1538
  // Build snake→camel map and JSON column set from schema
1365
1539
  const nameMap = new Map();
@@ -1396,6 +1570,29 @@ export function reshapeRow(row, childData, unionData) {
1396
1570
  }
1397
1571
  }
1398
1572
  }
1573
+ // A strongRef is stored as two columns so the uri can be joined on, and
1574
+ // reads back as the object the lexicon declares: `{ uri, cid }`. The
1575
+ // generated type for the field says so, and a handler written against it
1576
+ // must find it.
1577
+ if (schema) {
1578
+ for (const col of schema.columns) {
1579
+ if (!col.originalName.endsWith('__cid'))
1580
+ continue;
1581
+ const field = col.originalName.slice(0, -'__cid'.length);
1582
+ const uri = value[field];
1583
+ const cid = value[col.originalName];
1584
+ delete value[col.originalName];
1585
+ if (uri == null && cid == null) {
1586
+ delete value[field];
1587
+ continue;
1588
+ }
1589
+ value[field] = { ...(uri != null ? { uri } : {}), ...(cid != null ? { cid } : {}) };
1590
+ }
1591
+ }
1592
+ // Only space rows carry a space, and an explicit null on every other row
1593
+ // would put a field on the wire that means nothing there.
1594
+ if (envelope.space == null)
1595
+ delete envelope.space;
1399
1596
  // Reconstruct decomposed array fields from child data
1400
1597
  if (schema && childData) {
1401
1598
  for (const child of schema.children) {
@@ -1404,8 +1601,7 @@ export function reshapeRow(row, childData, unionData) {
1404
1601
  value[child.fieldName] = childRows.map((cr) => {
1405
1602
  const item = {};
1406
1603
  for (const col of child.columns) {
1407
- const raw = cr[col.name];
1408
- item[col.originalName] = normalizeValue(raw);
1604
+ item[col.originalName] = decodeColumn(col, cr[col.name]);
1409
1605
  }
1410
1606
  return item;
1411
1607
  });
@@ -1429,7 +1625,7 @@ export function reshapeRow(row, childData, unionData) {
1429
1625
  const items = branchRows.map((br) => {
1430
1626
  const item = {};
1431
1627
  for (const col of branch.columns) {
1432
- item[col.originalName] = normalizeValue(br[col.name]);
1628
+ item[col.originalName] = decodeColumn(col, br[col.name]);
1433
1629
  }
1434
1630
  return item;
1435
1631
  });
@@ -1441,7 +1637,7 @@ export function reshapeRow(row, childData, unionData) {
1441
1637
  const br = branchRows[0];
1442
1638
  const props = {};
1443
1639
  for (const col of branch.columns) {
1444
- props[col.originalName] = normalizeValue(br[col.name]);
1640
+ props[col.originalName] = decodeColumn(col, br[col.name]);
1445
1641
  }
1446
1642
  if (branch.wrapperField) {
1447
1643
  value[union.fieldName] = { $type: branch.type, [branch.wrapperField]: props };