Skip to content

Commit cf3778b

Browse files
committed
fix: features contract + FTS + test updates
1 parent 52a14a5 commit cf3778b

6 files changed

Lines changed: 84 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,8 @@ await exporter.exportToFile('/backups/snapshot.db');
496496
const bytes = await exporter.exportToBytes();
497497
```
498498

499+
On Postgres, `features.exporter` requires a Node runtime plus an adapter that was created with a connection string in `adapter.options.connectionString`, since `pg_dump` runs out-of-process.
500+
499501
## CI, Releases, and Badges
500502

501503
- GitHub Actions workflows:

src/core/contracts/features.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@ import { BrowserBlobCodec } from '../../codecs/BrowserBlobCodec.js';
1212
import { SqliteFileExporter } from '../../exporters/SqliteFileExporter.js';
1313
import { PostgresExporter } from '../../exporters/PostgresExporter.js';
1414

15+
interface PostgresAdapterOptionsLike {
16+
connectionString?: string;
17+
host?: string;
18+
port?: number;
19+
database?: string;
20+
user?: string;
21+
password?: string;
22+
ssl?: boolean | object;
23+
}
24+
25+
function extractPostgresConnectionString(adapter: StorageAdapter): string | undefined {
26+
const options = (adapter as StorageAdapter & { options?: PostgresAdapterOptionsLike }).options;
27+
if (!options) return undefined;
28+
if (options.connectionString) return options.connectionString;
29+
if (!options.database) return undefined;
30+
31+
const host = options.host ?? 'localhost';
32+
const port = options.port ?? 5432;
33+
const user = options.user ? encodeURIComponent(options.user) : '';
34+
const password = options.password ? `:${encodeURIComponent(options.password)}` : '';
35+
const auth = user ? `${user}${password}@` : '';
36+
const sslQuery = options.ssl ? '?sslmode=require' : '';
37+
return `postgresql://${auth}${host}:${port}/${options.database}${sslQuery}`;
38+
}
39+
1540
/**
1641
* Bundle of platform-aware database features.
1742
*
@@ -34,11 +59,14 @@ export interface StorageFeatures {
3459
export function createStorageFeatures(adapter: StorageAdapter): StorageFeatures {
3560
const isPostgres = adapter.kind === 'postgres';
3661
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
62+
const postgresConnectionString = isPostgres ? extractPostgresConnectionString(adapter) : undefined;
3763

3864
return {
3965
dialect: isPostgres ? new PostgresDialect() : new SqliteDialect(),
4066
fts: isPostgres ? new PostgresFts() : new SqliteFts5(),
4167
blobCodec: isBrowser ? new BrowserBlobCodec() : new NodeBlobCodec(),
42-
exporter: isPostgres ? new PostgresExporter() : new SqliteFileExporter(adapter),
68+
exporter: isPostgres
69+
? new PostgresExporter(postgresConnectionString)
70+
: new SqliteFileExporter(adapter),
4371
};
4472
}

src/fts/PostgresFts.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ export class PostgresFts implements IFullTextSearch {
1111
/** The content table is stored during createIndex for use by other methods. */
1212
private _contentTable = '';
1313
private _columns: string[] = [];
14+
private _lang = 'english';
1415

1516
createIndex(config: { table: string; columns: string[]; contentTable?: string; tokenizer?: string }): string {
1617
const ct = config.contentTable ?? config.table;
1718
this._contentTable = ct;
1819
this._columns = config.columns;
1920
const colConcat = config.columns.map((c) => `COALESCE(${c}, '')`).join(" || ' ' || ");
2021
const lang = this._tokenizerToLang(config.tokenizer);
22+
this._lang = lang;
2123

2224
return [
2325
`ALTER TABLE ${ct} ADD COLUMN IF NOT EXISTS _tsv tsvector`,
@@ -27,24 +29,26 @@ export class PostgresFts implements IFullTextSearch {
2729
}
2830

2931
matchClause(_indexName: string, queryPlaceholder: string): string {
30-
return `_tsv @@ plainto_tsquery('english', ${queryPlaceholder})`;
32+
return `_tsv @@ plainto_tsquery('${this._lang}', ${queryPlaceholder})`;
3133
}
3234

3335
rankExpression(_indexName: string, queryPlaceholder?: string): string {
3436
const qp = queryPlaceholder ?? '$1';
35-
return `ts_rank(_tsv, plainto_tsquery('english', ${qp}))`;
37+
return `ts_rank(_tsv, plainto_tsquery('${this._lang}', ${qp}))`;
3638
}
3739

3840
rebuildCommand(_indexName: string): string {
3941
const ct = this._contentTable;
4042
const colConcat = this._columns.map((c) => `COALESCE(${c}, '')`).join(" || ' ' || ");
41-
return `UPDATE ${ct} SET _tsv = to_tsvector('english', ${colConcat})`;
43+
return `UPDATE ${ct} SET _tsv = to_tsvector('${this._lang}', ${colConcat})`;
4244
}
4345

44-
syncInsert(_indexName: string, rowIdExpr: string, columns: string[]): string {
46+
syncInsert(_indexName: string, _rowIdExpr: string, columns: string[]): string {
4547
const ct = this._contentTable;
46-
const colConcat = columns.map((c) => `COALESCE(${c}, '')`).join(" || ' ' || ");
47-
return `UPDATE ${ct} SET _tsv = to_tsvector('english', ${colConcat}) WHERE rowid = ${rowIdExpr}`;
48+
const colConcat = columns
49+
.map((_, index) => `COALESCE($${index + 2}, '')`)
50+
.join(" || ' ' || ");
51+
return `UPDATE ${ct} SET _tsv = to_tsvector('${this._lang}', ${colConcat}) WHERE id = $1`;
4852
}
4953

5054
sanitizeQuery(input: string): string {

tests/features.spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SqliteFileExporter } from '../src/exporters/SqliteFileExporter.js';
99
import { PostgresExporter } from '../src/exporters/PostgresExporter.js';
1010
import type { StorageAdapter } from '../src/core/contracts/index.js';
1111

12-
function mockAdapter(kind: string): StorageAdapter {
12+
function mockAdapter(kind: string, extra: Record<string, unknown> = {}): StorageAdapter {
1313
return {
1414
kind,
1515
capabilities: new Set(),
@@ -20,6 +20,7 @@ function mockAdapter(kind: string): StorageAdapter {
2020
exec: async () => {},
2121
transaction: async (fn) => fn({} as StorageAdapter),
2222
close: async () => {},
23+
...extra,
2324
} as StorageAdapter;
2425
}
2526

@@ -39,9 +40,15 @@ describe('createStorageFeatures', () => {
3940
});
4041

4142
it('returns PostgresDialect + PostgresFts for postgres adapter', () => {
42-
const features = createStorageFeatures(mockAdapter('postgres'));
43+
const features = createStorageFeatures(
44+
mockAdapter('postgres', {
45+
options: { connectionString: 'postgresql://user:pass@localhost:5432/testdb' },
46+
}),
47+
);
4348
expect(features.dialect).toBeInstanceOf(PostgresDialect);
4449
expect(features.fts).toBeInstanceOf(PostgresFts);
4550
expect(features.exporter).toBeInstanceOf(PostgresExporter);
51+
expect((features.exporter as PostgresExporter & { connectionString?: string })['connectionString'])
52+
.toBe('postgresql://user:pass@localhost:5432/testdb');
4653
});
4754
});

tests/fts.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ describe('PostgresFts', () => {
6161
table: 'memory_traces_fts',
6262
columns: ['content', 'tags'],
6363
contentTable: 'memory_traces',
64+
tokenizer: 'simple',
6465
});
6566
expect(ddl).toContain('ADD COLUMN IF NOT EXISTS _tsv tsvector');
6667
expect(ddl).toContain('USING GIN');
68+
expect(ddl).toContain("to_tsvector('simple'");
6769
});
6870

6971
it('matchClause generates @@ expression', () => {
@@ -77,6 +79,20 @@ describe('PostgresFts', () => {
7779
expect(expr).toContain('ts_rank');
7880
});
7981

82+
it('syncInsert updates by id using bound values instead of rowid', () => {
83+
fts.createIndex({
84+
table: 'memory_traces_fts',
85+
columns: ['content', 'tags'],
86+
contentTable: 'memory_traces',
87+
tokenizer: 'simple',
88+
});
89+
const sql = fts.syncInsert('memory_traces_fts', '?', ['content', 'tags']);
90+
expect(sql).toContain('WHERE id = $1');
91+
expect(sql).toContain("COALESCE($2, '')");
92+
expect(sql).toContain("COALESCE($3, '')");
93+
expect(sql).toContain("to_tsvector('simple'");
94+
});
95+
8096
it('rebuildCommand generates UPDATE with to_tsvector', () => {
8197
const cmd = fts.rebuildCommand('memory_traces_fts');
8298
expect(cmd).toContain('UPDATE');

tests/postgres.dialect.integration.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,24 @@ describeIf('Postgres dialect integration', () => {
170170
expect(rows.length).toBeGreaterThanOrEqual(1);
171171
});
172172

173+
it('fts.syncInsert updates the tsvector column for newly inserted rows', async () => {
174+
await adapter.run(
175+
'INSERT INTO test_memory_traces (id, content, tags) VALUES ($1, $2, $3)',
176+
['t7', 'event sourcing and domain events', '["ddd"]'],
177+
);
178+
179+
await adapter.run(
180+
features.fts.syncInsert('test_memory_traces_fts', '$1', ['content', 'tags']),
181+
['t7', 'event sourcing and domain events', '["ddd"]'],
182+
);
183+
184+
const rows = await adapter.all<{ id: string }>(
185+
`SELECT id FROM test_memory_traces WHERE ${features.fts.matchClause('test_memory_traces_fts', '$1')}`,
186+
['event sourcing'],
187+
);
188+
expect(rows.some((row) => row.id === 't7')).toBe(true);
189+
});
190+
173191
it('blobCodec roundtrip works', async () => {
174192
const testVec = [0.1, 0.2, -0.5, 1.0];
175193
const encoded = features.blobCodec.encode(testVec);

0 commit comments

Comments
 (0)