diff --git a/apps/web/package.json b/apps/web/package.json index f6c4ad5..7643e03 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "@atcute/oauth-node-client": "^1.1.0", "@atcute/tid": "^1.1.2", "@atcute/xrpc-server": "^0.1.12", + "@atmo-dev/contrail-appview": "0.12.2", "@atmo-dev/contrail-lexicons": "^0.4.4", "@cloudflare/workers-types": "^4.20260317.1", "@eslint/compat": "^2.0.3", diff --git a/apps/web/src/lib/contrail/fts-indexing.test.ts b/apps/web/src/lib/contrail/fts-indexing.test.ts new file mode 100644 index 0000000..56ce513 --- /dev/null +++ b/apps/web/src/lib/contrail/fts-indexing.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + applyEvents, + buildFtsTables, + ftsQueryClause, + initSchema, + resolveConfig, + sqliteDialect, + type Database, + type IngestEvent, + type Statement +} from '@atmo-dev/contrail-appview'; + +type CapturedStatement = Statement & { + sql: string; + params: unknown[]; +}; + +function recordingDb({ legacyFts = false }: { legacyFts?: boolean } = {}) { + const runs: CapturedStatement[] = []; + const batches: CapturedStatement[][] = []; + + const prepare = (sql: string): CapturedStatement => { + const statement: CapturedStatement = { + sql, + params: [], + bind(...values: unknown[]) { + statement.params = values; + return statement; + }, + async run() { + runs.push(statement); + return {}; + }, + async all() { + const results = + legacyFts && sql.startsWith('PRAGMA table_info(fts_event)') + ? [{ name: 'uri' }, { name: 'content' }] + : []; + return { results: results as T[] }; + }, + async first() { + if (sql.includes('AS expected_rows')) { + return { + expected_rows: 0, + mapping_rows: 0, + fts_rows: 0, + joined_rows: 0 + } as T; + } + return null; + } + }; + return statement; + }; + + const db: Database = { + prepare, + async batch(statements) { + batches.push(statements as CapturedStatement[]); + return []; + }, + dialect: sqliteDialect + }; + + return { db, runs, batches }; +} + +const config = resolveConfig({ + namespace: 'rsvp.atmo', + logger: { log() {}, warn() {}, error() {} }, + collections: { + event: { + collection: 'community.lexicon.calendar.event', + queryable: { name: {} }, + searchable: ['name'] + } + } +}); + +const event: IngestEvent = { + uri: 'at://did:plc:alice/community.lexicon.calendar.event/one', + did: 'did:plc:alice', + collection: 'community.lexicon.calendar.event', + rkey: 'one', + operation: 'create', + cid: 'bafy-event', + record: JSON.stringify({ name: 'Indexed meetup' }), + time_us: 1, + indexed_at: 1 +}; + +describe('Contrail D1 FTS indexing backport', () => { + it('maintains FTS rows through the indexed URI map, never a virtual-table URI scan', async () => { + const { db, batches } = recordingDb(); + + await applyEvents(db, [event], config, { + skipReplayDetection: true, + skipFeedFanout: true + }); + + const sql = batches.flat().map((statement) => statement.sql); + expect(sql).toContain( + 'INSERT INTO fts_event_rows (uri) VALUES (?) ON CONFLICT(uri) DO NOTHING' + ); + expect(sql).toContain( + 'DELETE FROM fts_event WHERE rowid = (SELECT id FROM fts_event_rows WHERE uri = ?)' + ); + expect(sql).toContain( + 'INSERT INTO fts_event (rowid, content) SELECT id, ? FROM fts_event_rows WHERE uri = ?' + ); + expect(sql).not.toContain('DELETE FROM fts_event WHERE uri = ?'); + }); + + it('migrates the deployed uri-bearing FTS table before accepting the new schema', async () => { + const { db, batches } = recordingDb({ legacyFts: true }); + + await initSchema(db, config); + + const migrationSql = batches.flat().map((statement) => statement.sql); + expect(migrationSql).toContain('DROP TABLE IF EXISTS fts_event'); + expect(migrationSql).toContain('DROP TABLE IF EXISTS fts_event_rows'); + expect( + migrationSql.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS fts_event_rows')) + ).toBe(true); + expect(migrationSql).toContain( + 'CREATE VIRTUAL TABLE IF NOT EXISTS fts_event USING fts5(content)' + ); + expect(migrationSql.some((sql) => sql.startsWith('INSERT INTO fts_event_rows (uri)'))).toBe( + true + ); + expect( + migrationSql.some((sql) => sql.startsWith('INSERT INTO fts_event (rowid, content)')) + ).toBe(true); + }); + + it('uses the ordinary URI index to join search results back to records', () => { + expect(buildFtsTables(config, sqliteDialect)).toEqual([ + expect.stringContaining('uri TEXT NOT NULL UNIQUE'), + 'CREATE VIRTUAL TABLE IF NOT EXISTS fts_event USING fts5(content)' + ]); + expect(ftsQueryClause(sqliteDialect, 'records_event').join).toBe( + 'JOIN fts_event_rows fts_rows ON fts_rows.uri = r.uri JOIN fts_event fts ON fts.rowid = fts_rows.id' + ); + }); +}); diff --git a/patches/@atmo-dev__contrail-appview@0.12.2.patch b/patches/@atmo-dev__contrail-appview@0.12.2.patch new file mode 100644 index 0000000..4cf2376 --- /dev/null +++ b/patches/@atmo-dev__contrail-appview@0.12.2.patch @@ -0,0 +1,330 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 5642d1065975c910c29fe4fc7a9a1b6151cf40ed..2ba9f66ddc176a27a68b7fd9fdf98c298946fbe3 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -226,8 +226,10 @@ declare function refresh(db: Database, config: ContrailConfig, options?: Refresh + * Returns null if search is disabled or no fields found. + */ + declare function getSearchableFields(collection: string, colConfig: CollectionConfig): string[] | null; +-/** Sanitized FTS table name for a collection. */ ++/** Sanitized FTS virtual-table name for a collection. */ + declare function ftsTableName(collection: string): string; ++/** Ordinary unique URI-to-FTS-rowid mapping table for a collection. */ ++declare function ftsRowTableName(collection: string): string; + /** Extract searchable field values from a record and join them into a single string. */ + declare function buildFtsContent(record: unknown, fields: string[]): string | null; + +@@ -241,7 +243,7 @@ declare function backfillFollowersFromConstellation(db: Database, config: Contra + * table shapes (their DDL isn't all enumerated into the fingerprint). Pure + * config-driven changes (collections, feeds, indexes, migrations) bust the + * fingerprint automatically and don't need a bump. */ +-declare const CONTRAIL_SCHEMA_VERSION = 1; ++declare const CONTRAIL_SCHEMA_VERSION = 2; + interface BuilderOpts { + /** Emit tables for the spaces variant (spaces_records_ with space_uri column). */ + forSpaces?: boolean; +@@ -794,4 +796,4 @@ declare function resetLabelerCursor(db: Database, did: string): Promise; + * per-labeler. */ + declare function buildLabelsSchema(dialect: SqlDialect): string[]; + +-export { type BackfillAllOptions, type BackfillOptions, type BackfillProgress, CONTRAIL_SCHEMA_VERSION, type CollectionStats, type CreateAppOptions, type ExistingRecordInfo, FEED_PRUNE_RECOVERY_INTERVAL_MS, FEED_PRUNE_SWEEP_ACTORS, type FeedSweepResult, type FormattedRecord, HostedAdapter, type HydrateResult, type HydratedLabel, type IncomingLabel, type IngestState, type InitSchemaOptions, type LabelerResolveOverrides, type LabelerState, type NotifyResult, OPTIMIZE_LAST_MS_KEY, type PersistentIngestOptions, type PersistentLabelsOptions, type ProfileEntry, type QueryOptions, type RealtimeRoutesOptions, type RecordHostSyncOptions, type RecordHostSyncSource, type ReferenceResult, type RefreshOptions, type RefreshProgress, type RefreshResult, type SchemaModule, type SelectedLabelers, type SortOption, type SpacesContext, type SpacesRoutesOptions, type TopicResolution, type TopicResolutionContext, type TopicResolutionError, addColumnIfNotExists, applyCountColumns, applyEvents, applyLabels, applyRecordSyncSchema, backfillFollowersFromConstellation, backfillPending, backfillUser, batchedInQuery, buildCollectionTables, buildCountColumns, buildDynamicIndexes, buildFtsContent, buildFtsTables, buildLabelsSchema, buildRecordSyncSchema, collectDids, createApp, createIngestState, discoverDIDs, fieldToParam, formatRecord, ftsTableName, getFeedPruneCursor, getLabelerState, getLastCursor, getMeta, getMetaNumber, getSearchableFields, hydrateLabels, ingestEvents, initSchema, lookupExistingRecords, maybeOptimize, optimizeDatabase, parseAtUri, parseHydrateParams, parseIntParam, processNotifyUris, pruneActorFeed, pruneFeedItems, queryAcrossSources, queryRecords, refresh, registerAdminRoutes, registerCollectionRoutes, registerFeedRoutes, registerNotifyRoute, registerRealtimeRoutes, registerSpacesRoutes, resetLabelerCursor, resolveHydrates, resolveLabelerEndpoint, resolveProfiles, resolveReferences, resolveTopicForCaller, runFeedPruneSlice, runGatedFeedPrune, runIngestCycle, runLabelIngestCycle, runPersistent, runPersistentLabels, runPipeline, runRecordHostSync, saveCursor, saveFeedPruneCursor, saveLabelerCursor, selectAcceptedLabelers, setMeta, sweepFeedItems, validateEndpointUrl, wrapWithPublishing }; ++export { type BackfillAllOptions, type BackfillOptions, type BackfillProgress, CONTRAIL_SCHEMA_VERSION, type CollectionStats, type CreateAppOptions, type ExistingRecordInfo, FEED_PRUNE_RECOVERY_INTERVAL_MS, FEED_PRUNE_SWEEP_ACTORS, type FeedSweepResult, type FormattedRecord, HostedAdapter, type HydrateResult, type HydratedLabel, type IncomingLabel, type IngestState, type InitSchemaOptions, type LabelerResolveOverrides, type LabelerState, type NotifyResult, OPTIMIZE_LAST_MS_KEY, type PersistentIngestOptions, type PersistentLabelsOptions, type ProfileEntry, type QueryOptions, type RealtimeRoutesOptions, type RecordHostSyncOptions, type RecordHostSyncSource, type ReferenceResult, type RefreshOptions, type RefreshProgress, type RefreshResult, type SchemaModule, type SelectedLabelers, type SortOption, type SpacesContext, type SpacesRoutesOptions, type TopicResolution, type TopicResolutionContext, type TopicResolutionError, addColumnIfNotExists, applyCountColumns, applyEvents, applyLabels, applyRecordSyncSchema, backfillFollowersFromConstellation, backfillPending, backfillUser, batchedInQuery, buildCollectionTables, buildCountColumns, buildDynamicIndexes, buildFtsContent, buildFtsTables, buildLabelsSchema, buildRecordSyncSchema, collectDids, createApp, createIngestState, discoverDIDs, fieldToParam, formatRecord, ftsRowTableName, ftsTableName, getFeedPruneCursor, getLabelerState, getLastCursor, getMeta, getMetaNumber, getSearchableFields, hydrateLabels, ingestEvents, initSchema, lookupExistingRecords, maybeOptimize, optimizeDatabase, parseAtUri, parseHydrateParams, parseIntParam, processNotifyUris, pruneActorFeed, pruneFeedItems, queryAcrossSources, queryRecords, refresh, registerAdminRoutes, registerCollectionRoutes, registerFeedRoutes, registerNotifyRoute, registerRealtimeRoutes, registerSpacesRoutes, resetLabelerCursor, resolveHydrates, resolveLabelerEndpoint, resolveProfiles, resolveReferences, resolveTopicForCaller, runFeedPruneSlice, runGatedFeedPrune, runIngestCycle, runLabelIngestCycle, runPersistent, runPersistentLabels, runPipeline, runRecordHostSync, saveCursor, saveFeedPruneCursor, saveLabelerCursor, selectAcceptedLabelers, setMeta, sweepFeedItems, validateEndpointUrl, wrapWithPublishing }; +diff --git a/dist/index.js b/dist/index.js +index c97302002b9f1b701366b70ee8bb30881623f2df..eaf0a84a8a988422489087843f975d033f56f068 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -56,6 +56,7 @@ __export(index_exports, { + discoverDIDs: () => discoverDIDs, + fieldToParam: () => fieldToParam, + formatRecord: () => formatRecord, ++ ftsRowTableName: () => ftsRowTableName, + ftsTableName: () => ftsTableName, + getFeedPruneCursor: () => getFeedPruneCursor, + getLabelerState: () => getLabelerState, +@@ -273,8 +274,14 @@ function getSearchableFields(collection, colConfig) { + if (!Array.isArray(colConfig.searchable)) return null; + return colConfig.searchable.length > 0 ? colConfig.searchable : null; + } ++function sanitizedCollectionName(collection) { ++ return collection.replace(/[^a-zA-Z0-9]/g, "_"); ++} + function ftsTableName(collection) { +- return `fts_${collection.replace(/[^a-zA-Z0-9]/g, "_")}`; ++ return `fts_${sanitizedCollectionName(collection)}`; ++} ++function ftsRowTableName(collection) { ++ return `${ftsTableName(collection)}_rows`; + } + function buildFtsContent(record, fields) { + const parts = []; +@@ -284,7 +291,8 @@ function buildFtsContent(record, fields) { + parts.push(value); + } + } +- return parts.length > 0 ? parts.join(" ") : null; ++ const content = parts.join(" ").trim(); ++ return content.length > 0 ? content : null; + } + + // src/core/spaces/schema.ts +@@ -396,7 +404,7 @@ async function getMetaNumber(db, key) { + } + + // src/core/db/schema.ts +-var CONTRAIL_SCHEMA_VERSION = 1; ++var CONTRAIL_SCHEMA_VERSION = 2; + var SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; + function getResolved(config) { + return config._resolved ?? (0, types_exports.resolveConfig)(config)._resolved; +@@ -664,6 +672,172 @@ function buildFtsTables(config, dialect, opts = {}) { + } + return stmts; + } ++function sqliteFtsContentExpression(fields) { ++ const safeField = /^[a-zA-Z0-9_.]+$/; ++ const terms = fields.map((field) => { ++ if (!safeField.test(field)) throw new Error(`Invalid field name: ${field}`); ++ const path = `$.${field}`; ++ return `CASE WHEN json_type(record, '${path}') = 'text' THEN json_extract(record, '${path}') ELSE '' END`; ++ }); ++ const trimCodePoints = [ ++ 9, ++ 10, ++ 11, ++ 12, ++ 13, ++ 32, ++ 160, ++ 5760, ++ 8192, ++ 8193, ++ 8194, ++ 8195, ++ 8196, ++ 8197, ++ 8198, ++ 8199, ++ 8200, ++ 8201, ++ 8202, ++ 8232, ++ 8233, ++ 8239, ++ 8287, ++ 12288, ++ 65279 ++ ]; ++ return `trim(${terms.join(" || ' ' || ")}, char(${trimCodePoints.join(", ")}))`; ++} ++function ftsProjectionTables(collection, opts) { ++ const recordsTable = tableFor(collection, opts); ++ const ftsTable = recordsTable.replace("records_", "fts_"); ++ return { recordsTable, ftsTable, rowsTable: `${ftsTable}_rows` }; ++} ++async function virtualFtsColumns(db, table) { ++ const rows = await db.prepare(`PRAGMA table_info(${table})`).all(); ++ return (rows.results ?? []).map((row) => row.name); ++} ++function populateVirtualFtsStatements(db, collection, fields, opts) { ++ const { recordsTable, ftsTable, rowsTable } = ftsProjectionTables( ++ collection, ++ opts ++ ); ++ const content = sqliteFtsContentExpression(fields); ++ return [ ++ db.prepare( ++ `INSERT INTO ${rowsTable} (uri) ++ SELECT uri FROM ( ++ SELECT uri, ${content} AS content FROM ${recordsTable} ++ ) rebuilt ++ WHERE content <> '' ++ ORDER BY uri` ++ ), ++ db.prepare( ++ `INSERT INTO ${ftsTable} (rowid, content) ++ SELECT fts_rows.id, rebuilt.content ++ FROM ( ++ SELECT uri, ${content} AS content FROM ${recordsTable} ++ ) rebuilt ++ JOIN ${rowsTable} fts_rows ON fts_rows.uri = rebuilt.uri ++ WHERE rebuilt.content <> ''` ++ ) ++ ]; ++} ++async function verifyVirtualFtsProjection(db, collection, fields, opts) { ++ const { recordsTable, ftsTable, rowsTable } = ftsProjectionTables( ++ collection, ++ opts ++ ); ++ const content = sqliteFtsContentExpression(fields); ++ const row = await db.prepare( ++ `SELECT ++ (SELECT COUNT(*) FROM ( ++ SELECT uri, ${content} AS content FROM ${recordsTable} ++ ) expected WHERE content <> '') AS expected_rows, ++ (SELECT COUNT(*) FROM ${rowsTable}) AS mapping_rows, ++ (SELECT COUNT(*) FROM ${ftsTable}) AS fts_rows, ++ (SELECT COUNT(*) ++ FROM ${rowsTable} mapped ++ JOIN ${ftsTable} fts ON fts.rowid = mapped.id ++ JOIN ( ++ SELECT uri FROM ( ++ SELECT uri, ${content} AS content FROM ${recordsTable} ++ ) searchable ++ WHERE content <> '' ++ ) expected ON expected.uri = mapped.uri) AS joined_rows` ++ ).first(); ++ const expected = Number(row?.expected_rows ?? 0); ++ const mapping = Number(row?.mapping_rows ?? 0); ++ const fts = Number(row?.fts_rows ?? 0); ++ const joined = Number(row?.joined_rows ?? 0); ++ if (mapping !== expected || fts !== expected || joined !== expected) { ++ throw new Error( ++ `FTS migration verification failed for ${collection}: expected=${expected}, mapping=${mapping}, fts=${fts}, joined=${joined}` ++ ); ++ } ++} ++async function migrateLegacyVirtualFts(db, config, dialect, collection, fields, opts) { ++ const { recordsTable, ftsTable, rowsTable } = ftsProjectionTables( ++ collection, ++ opts ++ ); ++ const schema = (0, dialect_exports.buildFtsSchema)(dialect, recordsTable, fields); ++ await db.batch([ ++ db.prepare(`DROP TABLE IF EXISTS ${ftsTable}`), ++ db.prepare(`DROP TABLE IF EXISTS ${rowsTable}`), ++ ...schema.map((statement) => db.prepare(statement)), ++ ...populateVirtualFtsStatements(db, collection, fields, opts) ++ ]); ++ await verifyVirtualFtsProjection(db, collection, fields, opts); ++ (config.logger ?? console).log( ++ `[schema] migrated ${ftsTable} to indexed rowid maintenance` ++ ); ++} ++async function rebuildVirtualFtsProjection(db, collection, fields, opts) { ++ const { ftsTable, rowsTable } = ftsProjectionTables(collection, opts); ++ await db.batch([ ++ db.prepare(`DELETE FROM ${ftsTable}`), ++ db.prepare(`DELETE FROM ${rowsTable}`), ++ ...populateVirtualFtsStatements(db, collection, fields, opts) ++ ]); ++ await verifyVirtualFtsProjection(db, collection, fields, opts); ++} ++async function applyFtsTables(db, config, dialect, opts = {}) { ++ if (dialect.ftsStrategy === "generated-column") { ++ for (const statement of buildFtsTables(config, dialect, opts)) { ++ await db.prepare(statement).run(); ++ } ++ return; ++ } ++ for (const [collection, colConfig] of Object.entries(config.collections)) { ++ if (opts.forSpaces && colConfig.allowInSpaces === false) continue; ++ const fields = getSearchableFields(collection, colConfig); ++ if (!fields || fields.length === 0) continue; ++ const { recordsTable, ftsTable } = ftsProjectionTables(collection, opts); ++ const columns = await virtualFtsColumns(db, ftsTable); ++ if (columns.includes("uri")) { ++ await migrateLegacyVirtualFts( ++ db, ++ config, ++ dialect, ++ collection, ++ fields, ++ opts ++ ); ++ continue; ++ } ++ const schema = (0, dialect_exports.buildFtsSchema)(dialect, recordsTable, fields); ++ await db.prepare(schema[0]).run(); ++ try { ++ await db.prepare(schema[1]).run(); ++ } catch { ++ } ++ const currentColumns = columns.length > 0 ? columns : await virtualFtsColumns(db, ftsTable); ++ if (currentColumns.length > 0) { ++ await rebuildVirtualFtsProjection(db, collection, fields, opts); ++ } ++ } ++} + var MIGRATIONS = [ + { table: "backfills", column: "retries", columnDef: "INTEGER NOT NULL DEFAULT 0" }, + { table: "backfills", column: "last_error", columnDef: "TEXT" }, +@@ -699,13 +873,6 @@ async function applySpacesSchema(target, config, dialect) { + for (const stmt of [...base, ...perCollection, ...indexes]) { + await runIdempotentDdl(target, stmt); + } +- const ftsStmts = buildFtsTables(config, dialect, { forSpaces: true }); +- for (const stmt of ftsStmts) { +- try { +- await target.prepare(stmt).run(); +- } catch { +- } +- } + await applyCountColumns(target, config, { forSpaces: true }); + } + function hashStrings(parts) { +@@ -773,12 +940,7 @@ async function initSchema(db, config, options = {}) { + await runIdempotentDdl(db, stmt); + } + } +- for (const stmt of ftsStatements) { +- try { +- await db.prepare(stmt).run(); +- } catch { +- } +- } ++ await applyFtsTables(db, config, dialect); + const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost); + const hasFeeds = !!(config.feeds && Object.keys(config.feeds).length > 0); + await runMigrations(db, spacesSharesMainDb ? void 0 : spacesDb, hasSpaces, hasFeeds); +@@ -881,21 +1043,29 @@ function buildFtsStatements(db, event, config) { + const fields = getSearchableFields(short, colConfig); + if (!fields || fields.length === 0) return []; + const table = ftsTableName(short); +- const stmts = []; ++ const rowsTable = ftsRowTableName(short); ++ const deleteFtsRow = () => db.prepare( ++ `DELETE FROM ${table} WHERE rowid = (SELECT id FROM ${rowsTable} WHERE uri = ?)` ++ ).bind(event.uri); ++ const deleteMapping = () => db.prepare(`DELETE FROM ${rowsTable} WHERE uri = ?`).bind(event.uri); + if (event.operation === "delete") { +- stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); +- } else { +- const record = event.record ? JSON.parse(event.record) : null; +- if (!record) return []; +- stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); +- const content = buildFtsContent(record, fields); +- if (content) { +- stmts.push( +- db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content) +- ); +- } ++ return [deleteFtsRow(), deleteMapping()]; + } +- return stmts; ++ const record = event.record ? JSON.parse(event.record) : null; ++ if (!record) return []; ++ const content = buildFtsContent(record, fields); ++ if (!content) { ++ return [deleteFtsRow(), deleteMapping()]; ++ } ++ return [ ++ db.prepare( ++ `INSERT INTO ${rowsTable} (uri) VALUES (?) ON CONFLICT(uri) DO NOTHING` ++ ).bind(event.uri), ++ deleteFtsRow(), ++ db.prepare( ++ `INSERT INTO ${table} (rowid, content) SELECT id, ? FROM ${rowsTable} WHERE uri = ?` ++ ).bind(content, event.uri) ++ ]; + } + function buildFeedStatements(db, event, config, existingRecords) { + if (!config.feeds) return []; +@@ -5596,6 +5766,7 @@ export { + discoverDIDs, + fieldToParam, + formatRecord, ++ ftsRowTableName, + ftsTableName, + getFeedPruneCursor, + getLabelerState, diff --git a/patches/@atmo-dev__contrail-base@0.12.2.patch b/patches/@atmo-dev__contrail-base@0.12.2.patch new file mode 100644 index 0000000..a316ad7 --- /dev/null +++ b/patches/@atmo-dev__contrail-base@0.12.2.patch @@ -0,0 +1,30 @@ +diff --git a/dist/chunk-3XZU2VZ3.js b/dist/chunk-3XZU2VZ3.js +index 987bb992d678fadd6a2d7765bb8665c961d6f27a..be7353961fdbcf3c3372b955f91784a2a28891b4 100644 +--- a/dist/chunk-3XZU2VZ3.js ++++ b/dist/chunk-3XZU2VZ3.js +@@ -48,8 +48,13 @@ var postgresDialect = { + function buildFtsSchema(dialect, recordsTable, fields) { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); ++ const rowsTable = `${ftsTable}_rows`; + return [ +- `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(uri UNINDEXED, content)` ++ `CREATE TABLE IF NOT EXISTS ${rowsTable} ( ++ id INTEGER PRIMARY KEY, ++ uri TEXT NOT NULL UNIQUE ++ )`, ++ `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(content)` + ]; + } else { + const concatExpr = fields.map((f) => `COALESCE(${dialect.jsonExtract("record", f)}, '')`).join(" || ' ' || "); +@@ -62,8 +67,9 @@ function buildFtsSchema(dialect, recordsTable, fields) { + function ftsQueryClause(dialect, recordsTable) { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); ++ const rowsTable = `${ftsTable}_rows`; + return { +- join: `JOIN ${ftsTable} fts ON fts.uri = r.uri`, ++ join: `JOIN ${rowsTable} fts_rows ON fts_rows.uri = r.uri JOIN ${ftsTable} fts ON fts.rowid = fts_rows.id`, + condition: "fts.content MATCH ?", + orderExpr: "fts.rank" + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48d12a7..cd912ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@atmo-dev/contrail-appview@0.12.2': f4aa36f86a9f705620dcbd57e82d87abed38c36e5ff2c889e66ec9a0970f767f + '@atmo-dev/contrail-base@0.12.2': f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22 + importers: .: @@ -135,6 +139,9 @@ importers: '@atcute/xrpc-server': specifier: ^0.1.12 version: 0.1.12 + '@atmo-dev/contrail-appview': + specifier: 0.12.2 + version: 0.12.2(patch_hash=f4aa36f86a9f705620dcbd57e82d87abed38c36e5ff2c889e66ec9a0970f767f) '@atmo-dev/contrail-lexicons': specifier: ^0.4.4 version: 0.4.4(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1)) @@ -3849,7 +3856,7 @@ snapshots: '@badrap/valita': 0.4.6 nanoid: 5.1.7 - '@atmo-dev/contrail-appview@0.12.2': + '@atmo-dev/contrail-appview@0.12.2(patch_hash=f4aa36f86a9f705620dcbd57e82d87abed38c36e5ff2c889e66ec9a0970f767f)': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cbor': 2.3.2 @@ -3861,7 +3868,7 @@ snapshots: '@atcute/lexicons': 1.2.9 '@atcute/xrpc-server': 0.1.12 '@atmo-dev/contrail-authority': 0.12.2 - '@atmo-dev/contrail-base': 0.12.2 + '@atmo-dev/contrail-base': 0.12.2(patch_hash=f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22) '@atmo-dev/contrail-record-host': 0.12.2 hono: 4.12.14 transitivePeerDependencies: @@ -3872,12 +3879,12 @@ snapshots: dependencies: '@atcute/cid': 2.4.1 '@atcute/lexicons': 1.2.9 - '@atmo-dev/contrail-base': 0.12.2 + '@atmo-dev/contrail-base': 0.12.2(patch_hash=f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22) hono: 4.12.14 transitivePeerDependencies: - pg - '@atmo-dev/contrail-base@0.12.2': + '@atmo-dev/contrail-base@0.12.2(patch_hash=f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22)': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cid': 2.4.1 @@ -3900,7 +3907,7 @@ snapshots: '@atmo-dev/contrail-record-host@0.12.2': dependencies: '@atcute/cid': 2.4.1 - '@atmo-dev/contrail-base': 0.12.2 + '@atmo-dev/contrail-base': 0.12.2(patch_hash=f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22) hono: 4.12.14 transitivePeerDependencies: - pg @@ -3916,9 +3923,9 @@ snapshots: '@atcute/jetstream': 1.1.2 '@atcute/lexicons': 1.2.9 '@atcute/xrpc-server': 0.1.12 - '@atmo-dev/contrail-appview': 0.12.2 + '@atmo-dev/contrail-appview': 0.12.2(patch_hash=f4aa36f86a9f705620dcbd57e82d87abed38c36e5ff2c889e66ec9a0970f767f) '@atmo-dev/contrail-authority': 0.12.2 - '@atmo-dev/contrail-base': 0.12.2 + '@atmo-dev/contrail-base': 0.12.2(patch_hash=f13888cab253afcb9fe4fa92ace590d94e28ba71a58fea3919d6c2284daffa22) '@atmo-dev/contrail-record-host': 0.12.2 cac: 7.0.0 hono: 4.12.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 096ca65..e7332e1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,6 @@ allowBuilds: # just-published contrail for ~24h). Scope glob so we don't re-add per version. minimumReleaseAgeExclude: - '@atmo-dev/*' +patchedDependencies: + '@atmo-dev/contrail-appview@0.12.2': patches/@atmo-dev__contrail-appview@0.12.2.patch + '@atmo-dev/contrail-base@0.12.2': patches/@atmo-dev__contrail-base@0.12.2.patch