@lunora/d1 1.0.0-alpha.25 → 1.0.0-alpha.26

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/index.d.mts CHANGED
@@ -52,11 +52,21 @@ interface ExportGlobalArgs {
52
52
  tables?: ReadonlyArray<string>;
53
53
  }
54
54
  /**
55
- * Yield rows from every requested `.global()` table in batches. Uses
56
- * `LIMIT ?/OFFSET ?` because D1 globals don't have a stable keyset abstraction
57
- * here (the writer's `findMany` does, but at the cost of routing through the
58
- * full validator pipeline; for a snapshot stream a plain offset scan is
59
- * sufficient and predictable).
55
+ * Yield rows from every requested `.global()` table in batches.
56
+ *
57
+ * Keyset-paginates on the physical primary key (`id` every `.global()` table
58
+ * carries it, see `frameworkColumnDdl`): `WHERE "id" > ? ORDER BY "id" LIMIT ?`,
59
+ * carrying the last id forward. A plain `LIMIT/OFFSET` scan would be wrong here —
60
+ * SQLite gives no ordering guarantee for an unordered SELECT and each page is a
61
+ * separate query, so pages could overlap or skip rows (and any concurrent
62
+ * insert/delete would shift offsets and silently drop/duplicate rows in the
63
+ * snapshot). Keyset paging is deterministic under concurrent writes and avoids
64
+ * O(n^2) OFFSET scans on large tables.
65
+ *
66
+ * Tables are provisioned first (idempotent `CREATE … IF NOT EXISTS`): `.global()`
67
+ * tables are created lazily on first write, so a fresh deployment — or any table
68
+ * never written — would otherwise abort the stream with a raw `no such table`
69
+ * instead of exporting it as empty.
60
70
  */
61
71
  declare const exportGlobalRows: (exec: SqlCtxExec, schema: SchemaLike, args: ExportGlobalArgs) => AsyncGenerator<ExportRow, void, undefined>;
62
72
  interface ImportGlobalArgs {
@@ -90,7 +100,6 @@ declare const importGlobalRows: (writer: DatabaseWriterLike, schema: SchemaLike,
90
100
  */
91
101
  interface D1DatabaseLike {
92
102
  batch?: (statements: D1PreparedStatementLike[]) => Promise<unknown[]>;
93
- exec?: (sql: string) => Promise<unknown>;
94
103
  prepare: (sql: string) => D1PreparedStatementLike;
95
104
  withSession: (bookmark?: string) => D1SessionLike;
96
105
  }
package/dist/index.d.ts CHANGED
@@ -52,11 +52,21 @@ interface ExportGlobalArgs {
52
52
  tables?: ReadonlyArray<string>;
53
53
  }
54
54
  /**
55
- * Yield rows from every requested `.global()` table in batches. Uses
56
- * `LIMIT ?/OFFSET ?` because D1 globals don't have a stable keyset abstraction
57
- * here (the writer's `findMany` does, but at the cost of routing through the
58
- * full validator pipeline; for a snapshot stream a plain offset scan is
59
- * sufficient and predictable).
55
+ * Yield rows from every requested `.global()` table in batches.
56
+ *
57
+ * Keyset-paginates on the physical primary key (`id` every `.global()` table
58
+ * carries it, see `frameworkColumnDdl`): `WHERE "id" > ? ORDER BY "id" LIMIT ?`,
59
+ * carrying the last id forward. A plain `LIMIT/OFFSET` scan would be wrong here —
60
+ * SQLite gives no ordering guarantee for an unordered SELECT and each page is a
61
+ * separate query, so pages could overlap or skip rows (and any concurrent
62
+ * insert/delete would shift offsets and silently drop/duplicate rows in the
63
+ * snapshot). Keyset paging is deterministic under concurrent writes and avoids
64
+ * O(n^2) OFFSET scans on large tables.
65
+ *
66
+ * Tables are provisioned first (idempotent `CREATE … IF NOT EXISTS`): `.global()`
67
+ * tables are created lazily on first write, so a fresh deployment — or any table
68
+ * never written — would otherwise abort the stream with a raw `no such table`
69
+ * instead of exporting it as empty.
60
70
  */
61
71
  declare const exportGlobalRows: (exec: SqlCtxExec, schema: SchemaLike, args: ExportGlobalArgs) => AsyncGenerator<ExportRow, void, undefined>;
62
72
  interface ImportGlobalArgs {
@@ -90,7 +100,6 @@ declare const importGlobalRows: (writer: DatabaseWriterLike, schema: SchemaLike,
90
100
  */
91
101
  interface D1DatabaseLike {
92
102
  batch?: (statements: D1PreparedStatementLike[]) => Promise<unknown[]>;
93
- exec?: (sql: string) => Promise<unknown>;
94
103
  prepare: (sql: string) => D1PreparedStatementLike;
95
104
  withSession: (bookmark?: string) => D1SessionLike;
96
105
  }
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- export { exportGlobalRows, importGlobalRows, selectGlobalTables } from './packem_shared/exportGlobalRows-BgfNSwCG.mjs';
2
- export { D1Client, D1Session } from './packem_shared/D1Client-DA3flo1o.mjs';
1
+ export { exportGlobalRows, importGlobalRows, selectGlobalTables } from './packem_shared/exportGlobalRows-q0RgCLPM.mjs';
2
+ export { D1Client, D1Session } from './packem_shared/D1Client-DSj8p7c4.mjs';
3
3
  export { createD1CtxDb, readD1CdcChanges, runD1AggregateMigrations, runD1CdcMigration, runD1GlobalTableMigrations, runD1RankMigrations, runD1SearchMigrations, trimD1CdcChanges } from './packem_shared/createD1CtxDb-BMR8J0dT.mjs';
4
- export { facetGlobalColumn, listGlobalTables, readGlobalTablePage } from './packem_shared/facetGlobalColumn-DxrhOr12.mjs';
5
- export { MigrationRunner } from './packem_shared/MigrationRunner-BQx8HAGh.mjs';
4
+ export { facetGlobalColumn, listGlobalTables, readGlobalTablePage } from './packem_shared/facetGlobalColumn-DnglIe4P.mjs';
5
+ export { MigrationRunner } from './packem_shared/MigrationRunner-A-o1LTKj.mjs';
6
6
  export { default as sqliteDialect } from './packem_shared/sqliteDialect-DqYnHPuu.mjs';
7
7
  export { createSqlCtxDb } from '@lunora/sql-store';
@@ -1,6 +1,28 @@
1
1
  import { drizzle } from 'drizzle-orm/d1';
2
2
 
3
+ const evictOldestEntry = (map, capacity) => {
4
+ if (map.size < capacity) {
5
+ return;
6
+ }
7
+ const oldest = map.keys().next().value;
8
+ if (oldest !== void 0) {
9
+ map.delete(oldest);
10
+ }
11
+ };
12
+
3
13
  const STMT_CACHE_CAPACITY = 256;
14
+ const prepareCached = (cache, prepare, sql) => {
15
+ const cached = cache.get(sql);
16
+ if (cached) {
17
+ cache.delete(sql);
18
+ cache.set(sql, cached);
19
+ return cached;
20
+ }
21
+ const stmt = prepare(sql);
22
+ evictOldestEntry(cache, STMT_CACHE_CAPACITY);
23
+ cache.set(sql, stmt);
24
+ return stmt;
25
+ };
4
26
  const D1_FIRST_UNCONSTRAINED = "first-unconstrained";
5
27
  class D1Session {
6
28
  session;
@@ -10,21 +32,7 @@ class D1Session {
10
32
  this.session = session;
11
33
  }
12
34
  prepare(sql) {
13
- const cached = this.stmtCache.get(sql);
14
- if (cached) {
15
- this.stmtCache.delete(sql);
16
- this.stmtCache.set(sql, cached);
17
- return cached;
18
- }
19
- const stmt = this.session.prepare(sql);
20
- if (this.stmtCache.size >= STMT_CACHE_CAPACITY) {
21
- const oldest = this.stmtCache.keys().next().value;
22
- if (oldest !== void 0) {
23
- this.stmtCache.delete(oldest);
24
- }
25
- }
26
- this.stmtCache.set(sql, stmt);
27
- return stmt;
35
+ return prepareCached(this.stmtCache, (text) => this.session.prepare(text), sql);
28
36
  }
29
37
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T types the result rows for the caller and is forwarded to the prepared statement.
30
38
  async run(sql, ...binds) {
@@ -84,21 +92,7 @@ class D1Client {
84
92
  * even when the previous caller already called `.bind(...).run()`.
85
93
  */
86
94
  prepare(sql) {
87
- const cached = this.stmtCache.get(sql);
88
- if (cached) {
89
- this.stmtCache.delete(sql);
90
- this.stmtCache.set(sql, cached);
91
- return cached;
92
- }
93
- const stmt = this.db.prepare(sql);
94
- if (this.stmtCache.size >= STMT_CACHE_CAPACITY) {
95
- const oldest = this.stmtCache.keys().next().value;
96
- if (oldest !== void 0) {
97
- this.stmtCache.delete(oldest);
98
- }
99
- }
100
- this.stmtCache.set(sql, stmt);
101
- return stmt;
95
+ return prepareCached(this.stmtCache, (text) => this.db.prepare(text), sql);
102
96
  }
103
97
  /**
104
98
  * Drizzle handle over the bare `env.DB` binding. Used for typed queries
@@ -1,19 +1,19 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
2
  import { sql } from 'drizzle-orm';
3
- import { D1Client } from './D1Client-DA3flo1o.mjs';
3
+ import { D1Client } from './D1Client-DSj8p7c4.mjs';
4
4
 
5
5
  const TRACKING_TABLE_NAME = "__drizzle_migrations";
6
- const TRACKING_TABLE_DDL = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE_NAME} (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at NUMERIC)`;
6
+ const TRACKING_TABLE_DDL = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE_NAME} (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL UNIQUE, created_at NUMERIC)`;
7
7
  const WHITESPACE_RE = /\s/u;
8
- const TRAILING_SEMICOLON_RE = /;\s*$/u;
9
8
  const SHA256_HEX_RE = /^[0-9a-f]{64}$/u;
9
+ const TRACKING_HASH_UNIQUE_RE = new RegExp(String.raw`UNIQUE constraint failed:\s*${TRACKING_TABLE_NAME}\.hash`, "iu");
10
10
  const assertSingleStatement = (migration) => {
11
11
  const text = migration.sql;
12
12
  let inSingle = false;
13
13
  let inDouble = false;
14
14
  let inLineComment = false;
15
15
  let inBlockComment = false;
16
- let seenStatement = false;
16
+ let terminatorIndex;
17
17
  for (let index = 0; index < text.length; index += 1) {
18
18
  const character = text[index];
19
19
  const next = text[index + 1];
@@ -50,14 +50,6 @@ const assertSingleStatement = (migration) => {
50
50
  }
51
51
  continue;
52
52
  }
53
- if (character === "'") {
54
- inSingle = true;
55
- continue;
56
- }
57
- if (character === '"') {
58
- inDouble = true;
59
- continue;
60
- }
61
53
  if (character === "-" && next === "-") {
62
54
  inLineComment = true;
63
55
  index += 1;
@@ -68,17 +60,28 @@ const assertSingleStatement = (migration) => {
68
60
  index += 1;
69
61
  continue;
70
62
  }
71
- if (character === ";") {
72
- seenStatement = true;
63
+ if (terminatorIndex !== void 0) {
64
+ if (character !== void 0 && !WHITESPACE_RE.test(character)) {
65
+ throw new LunoraError(
66
+ "INTERNAL",
67
+ `Migration "${migration.name}" (v${String(migration.version)}) contains more than one SQL statement. Split it into separate migrations — batch() runs them atomically.`
68
+ );
69
+ }
73
70
  continue;
74
71
  }
75
- if (seenStatement && character !== void 0 && !WHITESPACE_RE.test(character)) {
76
- throw new LunoraError(
77
- "INTERNAL",
78
- `Migration "${migration.name}" (v${String(migration.version)}) contains more than one SQL statement. Split it into separate migrations — batch() runs them atomically.`
79
- );
72
+ if (character === "'") {
73
+ inSingle = true;
74
+ continue;
75
+ }
76
+ if (character === '"') {
77
+ inDouble = true;
78
+ continue;
79
+ }
80
+ if (character === ";") {
81
+ terminatorIndex = index;
80
82
  }
81
83
  }
84
+ return terminatorIndex;
82
85
  };
83
86
  const hashMigration = async (text) => {
84
87
  const bytes = new TextEncoder().encode(text);
@@ -112,20 +115,32 @@ class MigrationRunner {
112
115
  skipped.push({ name: migration.name, version: migration.version });
113
116
  continue;
114
117
  }
115
- await this.applyOne(migration, hash);
116
- applied.push({ name: migration.name, version: migration.version });
118
+ const didApply = await this.applyOne(migration, hash);
119
+ if (didApply) {
120
+ applied.push({ name: migration.name, version: migration.version });
121
+ } else {
122
+ skipped.push({ name: migration.name, version: migration.version });
123
+ }
117
124
  }
118
125
  return { applied, skipped };
119
126
  }
120
127
  async applyOne(migration, hash) {
121
- assertSingleStatement(migration);
122
- const statementText = migration.sql.replace(TRAILING_SEMICOLON_RE, "").trim();
128
+ const terminatorIndex = assertSingleStatement(migration);
129
+ const statementText = (terminatorIndex === void 0 ? migration.sql : migration.sql.slice(0, terminatorIndex)).trim();
123
130
  if (!SHA256_HEX_RE.test(hash)) {
124
131
  throw new LunoraError("INTERNAL", `migration "${migration.name}" produced a non-hex hash; refusing to inline into SQL`);
125
132
  }
126
133
  const trackingInsertSql = `INSERT INTO ${TRACKING_TABLE_NAME} (hash, created_at) VALUES ('${hash}', ${String(Date.now())})`;
127
134
  const items = [this.client.drizzle.run(sql.raw(statementText)), this.client.drizzle.run(sql.raw(trackingInsertSql))];
128
- await this.client.batch(items);
135
+ try {
136
+ await this.client.batch(items);
137
+ } catch (error) {
138
+ if (TRACKING_HASH_UNIQUE_RE.test(error instanceof Error ? error.message : String(error))) {
139
+ return false;
140
+ }
141
+ throw error;
142
+ }
143
+ return true;
129
144
  }
130
145
  assertUniqueVersions() {
131
146
  const seen = /* @__PURE__ */ new Set();
@@ -1,4 +1,5 @@
1
1
  import { toErrorBody } from '@lunora/errors';
2
+ import { runD1GlobalTableMigrations } from './createD1CtxDb-BMR8J0dT.mjs';
2
3
  import { quoteIdentifier } from './quoteIdentifier-B-ZeSe1V.mjs';
3
4
  import { decodeGlobalRow } from '@lunora/sql-store';
4
5
 
@@ -20,16 +21,21 @@ const decodeRow = (schema, table, row) => {
20
21
  const exportGlobalRows = async function* (exec, schema, args) {
21
22
  const tables = selectGlobalTables(schema, args.tables);
22
23
  const batchSize = args.batchSize ?? DEFAULT_BATCH_SIZE;
24
+ await runD1GlobalTableMigrations(exec, schema);
23
25
  for (const table of tables) {
24
- let offset = 0;
26
+ const quoted = quoteIdentifier(table);
27
+ let lastId;
25
28
  let hasMore = true;
26
29
  while (hasMore) {
27
- const rows = await exec.all(`SELECT * FROM ${quoteIdentifier(table)} LIMIT ? OFFSET ?`, [batchSize, offset]);
30
+ const rows = lastId === void 0 ? await exec.all(`SELECT * FROM ${quoted} ORDER BY "id" LIMIT ?`, [batchSize]) : await exec.all(`SELECT * FROM ${quoted} WHERE "id" > ? ORDER BY "id" LIMIT ?`, [lastId, batchSize]);
28
31
  for (const row of rows) {
29
32
  yield { doc: decodeRow(schema, table, row), table };
30
33
  }
34
+ const last = rows.at(-1);
35
+ if (last !== void 0) {
36
+ lastId = String(last["id"]);
37
+ }
31
38
  hasMore = rows.length === batchSize;
32
- offset += rows.length;
33
39
  }
34
40
  }
35
41
  };
@@ -1,5 +1,6 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
2
  import { runD1GlobalTableMigrations } from './createD1CtxDb-BMR8J0dT.mjs';
3
+ import { quoteIdentifier } from './quoteIdentifier-B-ZeSe1V.mjs';
3
4
  import { decodeGlobalRow } from '@lunora/sql-store';
4
5
 
5
6
  const ensureGlobalTables = (exec, schema) => runD1GlobalTableMigrations(exec, schema);
@@ -7,7 +8,6 @@ const DEFAULT_PAGE_SIZE = 50;
7
8
  const MAX_PAGE_SIZE = 500;
8
9
  const DEFAULT_FACET_LIMIT = 30;
9
10
  const MAX_FACET_LIMIT = 200;
10
- const quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
11
11
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
12
12
  const INTERNAL_TABLE = /^sqlite_|^_cf_|^d1_|^__cdc|__agg_|__rank_|__fts_/u;
13
13
  const isInternalTable = (name) => INTERNAL_TABLE.test(name);
@@ -31,6 +31,9 @@ const buildEqPredicate = (schema, table, displayColumns, filters) => {
31
31
  if (!displayColumns.includes(filter.column)) {
32
32
  throw new LunoraError("UNKNOWN_COLUMN", `unknown column: ${filter.column}`, { status: 404 });
33
33
  }
34
+ if (schema.tables[table] === void 0 && SENSITIVE_COLUMN.test(filter.column)) {
35
+ throw new LunoraError("FORBIDDEN", `cannot filter on a redacted column: ${filter.column}`, { status: 403 });
36
+ }
34
37
  const quoted = quoteIdentifier(physicalColumnName(schema, table, filter.column));
35
38
  if (filter.value === null || filter.value === void 0) {
36
39
  clauses.push(`${quoted} IS NULL`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/d1",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.26",
4
4
  "description": "D1 adapter for Lunora .global() tables, wrapping the Sessions API for read-your-writes",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/do": "1.0.0-alpha.26",
54
- "@lunora/errors": "1.0.0-alpha.3",
55
- "@lunora/sql-store": "1.0.0-alpha.26",
53
+ "@lunora/do": "1.0.0-alpha.27",
54
+ "@lunora/errors": "1.0.0-alpha.4",
55
+ "@lunora/sql-store": "1.0.0-alpha.27",
56
56
  "drizzle-orm": "^0.45.2"
57
57
  },
58
58
  "engines": {