Skip to content

Commit 621ca4f

Browse files
committed
feat(storage): add SqlDialect, FTS, BlobCodec, Exporter abstractions
- SqlDialect interface with SQLite + Postgres implementations - IFullTextSearch with FTS5 + tsvector/GIN implementations - IBlobCodec with Node (Buffer) + Browser (DataView) implementations - IDatabaseExporter with VACUUM INTO + pg_dump implementations - createStorageFeatures() factory selects by adapter.kind + runtime - 41 unit tests covering all implementations
1 parent 1180650 commit 621ca4f

19 files changed

Lines changed: 870 additions & 0 deletions

src/codecs/BrowserBlobCodec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { IBlobCodec } from '../core/contracts/blobCodec.js';
2+
3+
/**
4+
* Browser-compatible BLOB codec using typed arrays.
5+
*
6+
* Encodes number[] vectors as Float32Array binary BLOBs and computes
7+
* SHA-256 hashes via `crypto.subtle` (with a Node.js fallback for
8+
* test environments where SubtleCrypto may not be available).
9+
*/
10+
export class BrowserBlobCodec implements IBlobCodec {
11+
encode(vec: number[]): Uint8Array {
12+
const f32 = new Float32Array(vec);
13+
return new Uint8Array(f32.buffer);
14+
}
15+
16+
decode(blob: Uint8Array | ArrayBufferLike): number[] {
17+
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
18+
const f32 = new Float32Array(
19+
bytes.buffer,
20+
bytes.byteOffset,
21+
bytes.byteLength / 4,
22+
);
23+
return Array.from(f32);
24+
}
25+
26+
async sha256(input: string): Promise<string> {
27+
if (typeof globalThis.crypto?.subtle?.digest === 'function') {
28+
const encoded = new TextEncoder().encode(input);
29+
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', encoded);
30+
const hashArray = new Uint8Array(hashBuffer);
31+
return Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join('');
32+
}
33+
// Fallback for Node.js test environments
34+
const { createHash } = await import('node:crypto');
35+
return createHash('sha256').update(input, 'utf8').digest('hex');
36+
}
37+
}

src/codecs/NodeBlobCodec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import crypto from 'node:crypto';
2+
import type { IBlobCodec } from '../core/contracts/blobCodec.js';
3+
4+
/**
5+
* Node.js Buffer-based BLOB codec.
6+
*
7+
* Encodes number[] vectors as Float32 little-endian binary BLOBs using
8+
* `Buffer.writeFloatLE` / `Buffer.readFloatLE`, and computes SHA-256
9+
* hashes using the `node:crypto` module.
10+
*/
11+
export class NodeBlobCodec implements IBlobCodec {
12+
encode(vec: number[]): Uint8Array {
13+
const buf = Buffer.alloc(vec.length * 4);
14+
for (let i = 0; i < vec.length; i++) {
15+
buf.writeFloatLE(vec[i]!, i * 4);
16+
}
17+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
18+
}
19+
20+
decode(blob: Uint8Array | ArrayBufferLike): number[] {
21+
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
22+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
23+
const count = buf.length / 4;
24+
const vec: number[] = new Array(count);
25+
for (let i = 0; i < count; i++) {
26+
vec[i] = buf.readFloatLE(i * 4);
27+
}
28+
return vec;
29+
}
30+
31+
async sha256(input: string): Promise<string> {
32+
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
33+
}
34+
}

src/core/contracts/blobCodec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Binary BLOB codec for encoding/decoding vectors and computing hashes.
3+
*
4+
* Node.js implementations use Buffer.
5+
* Browser implementations use Uint8Array + DataView.
6+
*/
7+
export interface IBlobCodec {
8+
/** Encode a number[] vector to a binary BLOB value (Float32 little-endian). */
9+
encode(vec: number[]): Uint8Array;
10+
11+
/** Decode a BLOB column value back to number[]. */
12+
decode(blob: Uint8Array | ArrayBufferLike): number[];
13+
14+
/** SHA-256 hex digest. Async to support crypto.subtle in browsers. */
15+
sha256(input: string): Promise<string>;
16+
}

src/core/contracts/dialect.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* SQL dialect abstraction for cross-platform SQL generation.
3+
*
4+
* Each method returns a SQL string fragment or full statement.
5+
* Implementations are pure string transformers — no database calls.
6+
*/
7+
export interface SqlDialect {
8+
/** Dialect identifier. */
9+
readonly name: 'sqlite' | 'postgres';
10+
11+
/**
12+
* Generate an INSERT OR IGNORE statement.
13+
* SQLite: `INSERT OR IGNORE INTO t (a, b) VALUES (?, ?)`
14+
* Postgres: `INSERT INTO t (a, b) VALUES ($1, $2) ON CONFLICT DO NOTHING`
15+
*/
16+
insertOrIgnore(table: string, columns: string[], placeholders: string[]): string;
17+
18+
/**
19+
* Generate an INSERT OR REPLACE (upsert) statement.
20+
* SQLite: `INSERT OR REPLACE INTO t (a, b) VALUES (?, ?)`
21+
* Postgres: `INSERT INTO t (a, b) VALUES ($1, $2) ON CONFLICT (pk) DO UPDATE SET ...`
22+
*
23+
* @param primaryKey - Required for Postgres ON CONFLICT clause. Defaults to first column.
24+
*/
25+
insertOrReplace(table: string, columns: string[], placeholders: string[], primaryKey?: string): string;
26+
27+
/**
28+
* Generate a JSON field extraction expression.
29+
* SQLite: `json_extract(col, '$.key')`
30+
* Postgres: `(col::jsonb)->>'key'`
31+
*/
32+
jsonExtract(column: string, jsonPath: string): string;
33+
34+
/**
35+
* Generate a null-coalesce expression.
36+
* SQLite: `ifnull(expr, fallback)`
37+
* Postgres: `COALESCE(expr, fallback)`
38+
*/
39+
ifnull(expr: string, fallback: string): string;
40+
41+
/**
42+
* Column definition for an auto-incrementing integer primary key.
43+
* SQLite: `INTEGER PRIMARY KEY AUTOINCREMENT`
44+
* Postgres: `INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY`
45+
*/
46+
autoIncrementPrimaryKey(): string;
47+
48+
/**
49+
* Generate a PRAGMA statement or equivalent.
50+
* SQLite: `PRAGMA key = value`
51+
* Postgres: returns null (skip — Postgres enforces FKs by default, etc.)
52+
*/
53+
pragma(key: string, value: string): string | null;
54+
55+
/**
56+
* Parameter placeholder for the given 0-based index.
57+
* SQLite: `?`
58+
* Postgres: `$1`, `$2`, etc.
59+
*/
60+
placeholder(index: number): string;
61+
}

src/core/contracts/exporter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Database export abstraction.
3+
*
4+
* SQLite: VACUUM INTO or db.export().
5+
* Postgres: pg_dump via child_process.
6+
*/
7+
export interface IDatabaseExporter {
8+
/** Export the full database to a file at the given path. */
9+
exportToFile(outputPath: string): Promise<void>;
10+
11+
/** Export the full database as raw bytes. */
12+
exportToBytes(): Promise<Uint8Array>;
13+
}

src/core/contracts/features.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { SqlDialect } from './dialect.js';
2+
import type { IFullTextSearch } from './fts.js';
3+
import type { IBlobCodec } from './blobCodec.js';
4+
import type { IDatabaseExporter } from './exporter.js';
5+
import type { StorageAdapter } from './index.js';
6+
import { SqliteDialect } from '../../dialects/SqliteDialect.js';
7+
import { PostgresDialect } from '../../dialects/PostgresDialect.js';
8+
import { SqliteFts5 } from '../../fts/SqliteFts5.js';
9+
import { PostgresFts } from '../../fts/PostgresFts.js';
10+
import { NodeBlobCodec } from '../../codecs/NodeBlobCodec.js';
11+
import { BrowserBlobCodec } from '../../codecs/BrowserBlobCodec.js';
12+
import { SqliteFileExporter } from '../../exporters/SqliteFileExporter.js';
13+
import { PostgresExporter } from '../../exporters/PostgresExporter.js';
14+
15+
/**
16+
* Bundle of platform-aware database features.
17+
*
18+
* Created by `createStorageFeatures(adapter)` — consumers use this
19+
* instead of writing raw platform-specific SQL.
20+
*/
21+
export interface StorageFeatures {
22+
readonly dialect: SqlDialect;
23+
readonly fts: IFullTextSearch;
24+
readonly blobCodec: IBlobCodec;
25+
readonly exporter: IDatabaseExporter;
26+
}
27+
28+
/**
29+
* Create a platform-aware feature bundle for the given storage adapter.
30+
*
31+
* Inspects `adapter.kind` and the runtime environment to select the right
32+
* dialect, FTS, BLOB codec, and exporter implementations.
33+
*/
34+
export function createStorageFeatures(adapter: StorageAdapter): StorageFeatures {
35+
const isPostgres = adapter.kind === 'postgres';
36+
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
37+
38+
return {
39+
dialect: isPostgres ? new PostgresDialect() : new SqliteDialect(),
40+
fts: isPostgres ? new PostgresFts() : new SqliteFts5(),
41+
blobCodec: isBrowser ? new BrowserBlobCodec() : new NodeBlobCodec(),
42+
exporter: isPostgres ? new PostgresExporter() : new SqliteFileExporter(adapter),
43+
};
44+
}

src/core/contracts/fts.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Full-text search abstraction.
3+
*
4+
* SQLite implementations use FTS5 virtual tables.
5+
* Postgres implementations use tsvector columns with GIN indexes.
6+
* Methods return SQL strings — no database calls.
7+
*/
8+
export interface IFullTextSearch {
9+
/**
10+
* Generate DDL to create the full-text search index.
11+
*
12+
* @param config.table - Name for the FTS index/virtual table.
13+
* @param config.columns - Columns to index.
14+
* @param config.contentTable - Source table (for external-content FTS5).
15+
* @param config.tokenizer - Tokenizer config (e.g. 'porter ascii').
16+
*/
17+
createIndex(config: {
18+
table: string;
19+
columns: string[];
20+
contentTable?: string;
21+
tokenizer?: string;
22+
}): string;
23+
24+
/**
25+
* Generate a WHERE clause fragment for full-text matching.
26+
* SQLite: `memory_traces_fts MATCH ?`
27+
* Postgres: `memory_traces._tsv @@ plainto_tsquery('english', $1)`
28+
*/
29+
matchClause(indexName: string, queryPlaceholder: string): string;
30+
31+
/**
32+
* Generate an ORDER BY rank expression.
33+
* SQLite: `memory_traces_fts.rank`
34+
* Postgres: `ts_rank(memory_traces._tsv, plainto_tsquery('english', $1))`
35+
*/
36+
rankExpression(indexName: string, queryPlaceholder?: string): string;
37+
38+
/**
39+
* Generate the rebuild/reindex command.
40+
* SQLite: `INSERT INTO fts_table(fts_table) VALUES('rebuild')`
41+
* Postgres: `UPDATE content_table SET _tsv = to_tsvector('english', col1 || ' ' || col2)`
42+
*/
43+
rebuildCommand(indexName: string): string;
44+
45+
/**
46+
* Generate an INSERT to sync external-content FTS after a row insert.
47+
* SQLite: `INSERT INTO fts_table (rowid, col1, col2) VALUES (expr, ?, ?)`
48+
* Postgres: `UPDATE content_table SET _tsv = to_tsvector(...) WHERE ...`
49+
*/
50+
syncInsert(indexName: string, rowIdExpr: string, columns: string[]): string;
51+
52+
/**
53+
* Sanitize natural-language input into a safe search query.
54+
* SQLite: wraps words in quotes, strips FTS5 operators.
55+
* Postgres: pass-through (plainto_tsquery handles it).
56+
*/
57+
sanitizeQuery(input: string): string;
58+
59+
/**
60+
* Generate a SELECT joining the FTS index to the content table.
61+
* This handles the structural difference between FTS5 (separate virtual table
62+
* joined via rowid) and Postgres (tsvector column on the content table itself).
63+
*
64+
* @param contentTable - The base table (e.g. 'memory_traces').
65+
* @param contentAlias - Alias for the content table (e.g. 't').
66+
* @param ftsAlias - Alias for the FTS table/column (e.g. 'fts').
67+
* @param indexName - FTS index/virtual table name.
68+
* @returns FROM/JOIN clause fragment.
69+
*/
70+
joinClause(contentTable: string, contentAlias: string, ftsAlias: string, indexName: string): string;
71+
}

src/core/contracts/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,11 @@ export * from './performance';
283283
*/
284284
export * from './hooks';
285285

286+
// Dialect & feature contracts --------------------------------------------------
287+
export type { SqlDialect } from './dialect.js';
288+
export type { IFullTextSearch } from './fts.js';
289+
export type { IBlobCodec } from './blobCodec.js';
290+
export type { IDatabaseExporter } from './exporter.js';
291+
export type { StorageFeatures } from './features.js';
292+
export { createStorageFeatures } from './features.js';
293+

src/dialects/PostgresDialect.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { SqlDialect } from '../core/contracts/dialect.js';
2+
3+
/**
4+
* PostgreSQL dialect implementation.
5+
*
6+
* Generates Postgres-compatible SQL syntax including `ON CONFLICT DO NOTHING`,
7+
* `ON CONFLICT ... DO UPDATE SET`, `jsonb` extraction operators, `COALESCE()`,
8+
* `GENERATED ALWAYS AS IDENTITY`, and `$N` positional placeholders.
9+
*/
10+
export class PostgresDialect implements SqlDialect {
11+
readonly name = 'postgres' as const;
12+
13+
insertOrIgnore(table: string, columns: string[], placeholders: string[]): string {
14+
return `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) ON CONFLICT DO NOTHING`;
15+
}
16+
17+
insertOrReplace(table: string, columns: string[], placeholders: string[], primaryKey?: string): string {
18+
const pk = primaryKey ?? columns[0];
19+
const updates = columns
20+
.filter((col) => col !== pk)
21+
.map((col) => `${col} = EXCLUDED.${col}`)
22+
.join(', ');
23+
return `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) ON CONFLICT (${pk}) DO UPDATE SET ${updates}`;
24+
}
25+
26+
jsonExtract(column: string, jsonPath: string): string {
27+
const path = jsonPath.replace(/^\$\./, '');
28+
const parts = path.split('.');
29+
if (parts.length === 1) {
30+
return `(${column}::jsonb)->>'${parts[0]}'`;
31+
}
32+
const args = parts.map((p) => `'${p}'`).join(', ');
33+
return `jsonb_extract_path_text(${column}::jsonb, ${args})`;
34+
}
35+
36+
ifnull(expr: string, fallback: string): string {
37+
return `COALESCE(${expr}, ${fallback})`;
38+
}
39+
40+
autoIncrementPrimaryKey(): string {
41+
return 'INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY';
42+
}
43+
44+
pragma(_key: string, _value: string): string | null {
45+
return null;
46+
}
47+
48+
placeholder(index: number): string {
49+
return `$${index + 1}`;
50+
}
51+
}

src/dialects/SqliteDialect.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { SqlDialect } from '../core/contracts/dialect.js';
2+
3+
/**
4+
* SQLite dialect implementation.
5+
*
6+
* Generates standard SQLite SQL syntax including `INSERT OR IGNORE`,
7+
* `INSERT OR REPLACE`, `json_extract()`, `ifnull()`, `PRAGMA`, and
8+
* positional `?` placeholders.
9+
*/
10+
export class SqliteDialect implements SqlDialect {
11+
readonly name = 'sqlite' as const;
12+
13+
insertOrIgnore(table: string, columns: string[], placeholders: string[]): string {
14+
return `INSERT OR IGNORE INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`;
15+
}
16+
17+
insertOrReplace(table: string, columns: string[], placeholders: string[], _primaryKey?: string): string {
18+
return `INSERT OR REPLACE INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`;
19+
}
20+
21+
jsonExtract(column: string, jsonPath: string): string {
22+
return `json_extract(${column}, '${jsonPath}')`;
23+
}
24+
25+
ifnull(expr: string, fallback: string): string {
26+
return `ifnull(${expr}, ${fallback})`;
27+
}
28+
29+
autoIncrementPrimaryKey(): string {
30+
return 'INTEGER PRIMARY KEY AUTOINCREMENT';
31+
}
32+
33+
pragma(key: string, value: string): string | null {
34+
return `PRAGMA ${key} = ${value}`;
35+
}
36+
37+
placeholder(_index: number): string {
38+
return '?';
39+
}
40+
}

0 commit comments

Comments
 (0)