@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.
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -0
- package/dist/cli.js +5 -1
- package/dist/config.d.ts +47 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -1
- package/dist/database/db.d.ts +11 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +356 -160
- package/dist/database/dialect.js +2 -2
- package/dist/database/fts.d.ts.map +1 -1
- package/dist/database/fts.js +6 -2
- package/dist/database/index.d.ts +1 -1
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +1 -1
- package/dist/database/schema.d.ts +2 -0
- package/dist/database/schema.d.ts.map +1 -1
- package/dist/database/schema.js +46 -4
- package/dist/dev-entry.d.ts.map +1 -1
- package/dist/dev-entry.js +2 -2
- package/dist/feeds.d.ts +14 -0
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +24 -13
- package/dist/hooks.d.ts +17 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +26 -3
- package/dist/hydrate.d.ts +42 -0
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +18 -5
- package/dist/indexer.d.ts +4 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +81 -64
- package/dist/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- package/dist/main.js +26 -2
- package/dist/oauth/db.d.ts +9 -0
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +12 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +16 -4
- package/dist/private-collections.d.ts +7 -2
- package/dist/private-collections.d.ts.map +1 -1
- package/dist/private-collections.js +9 -4
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +167 -15
- package/dist/spaces/blob.d.ts +51 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +111 -0
- package/dist/spaces/credential.d.ts +62 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +184 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +552 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +88 -0
- package/dist/spaces/index.d.ts +55 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +133 -0
- package/dist/spaces/notify.d.ts +51 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +121 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +39 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +87 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +4 -0
- package/dist/templates/xrpc.tpl +13 -1
- package/dist/test.d.ts +2 -0
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +8 -5
- package/dist/xrpc.d.ts +19 -3
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +62 -32
- package/package.json +8 -4
package/dist/database/db.js
CHANGED
|
@@ -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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
385
|
+
await dropColumn();
|
|
345
386
|
emit('migration', 'drop_column', { table: change.table, column: change.column });
|
|
346
387
|
break;
|
|
347
388
|
case 'retype':
|
|
348
|
-
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
820
|
-
await port.execute(`
|
|
821
|
-
|
|
822
|
-
const
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
|
841
|
-
//
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
...
|
|
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
|
|
867
|
-
await port.execute(`DROP TABLE IF EXISTS ${
|
|
868
|
-
await port.execute(`CREATE TABLE ${
|
|
869
|
-
const
|
|
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
|
|
872
|
-
if (!
|
|
873
|
-
continue;
|
|
874
|
-
if (unionValue.$type !== branch.type)
|
|
939
|
+
const items = rec.record[child.fieldName];
|
|
940
|
+
if (!Array.isArray(items))
|
|
875
941
|
continue;
|
|
876
|
-
|
|
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
|
|
898
|
-
values.push(resolveRawColumnValue(col,
|
|
945
|
+
for (const col of child.columns) {
|
|
946
|
+
values.push(resolveRawColumnValue(col, item));
|
|
899
947
|
}
|
|
900
|
-
|
|
948
|
+
childInserter.append(values);
|
|
901
949
|
}
|
|
902
950
|
catch {
|
|
903
|
-
// Skip bad
|
|
951
|
+
// Skip bad items
|
|
904
952
|
}
|
|
905
953
|
}
|
|
906
954
|
}
|
|
907
|
-
await
|
|
908
|
-
// Delete existing
|
|
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 ${
|
|
911
|
-
const
|
|
912
|
-
const col =
|
|
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 ${
|
|
919
|
-
await port.execute(`DROP TABLE ${
|
|
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 =
|
|
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} ${
|
|
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
|
|
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
|
|
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
|
|
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, [
|
|
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
|
|
1260
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1362
|
-
|
|
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
|
-
|
|
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] =
|
|
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] =
|
|
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 };
|