@chipmobilesdk/rn-local-db 0.2.1 → 0.4.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +31 -12
  3. package/package.json +5 -2
  4. package/skills/sdk-rn-local-db/SKILL.md +1 -1
  5. package/src/adapter/budgets.ts +1 -10
  6. package/src/adapter/collection.ts +38 -66
  7. package/src/adapter/deletion.ts +48 -0
  8. package/src/adapter/handle.ts +311 -0
  9. package/src/adapter/lifecycle.ts +10 -338
  10. package/src/adapter/schema-sync.ts +155 -0
  11. package/src/artifacts/cascade.ts +0 -14
  12. package/src/artifacts/store.ts +60 -10
  13. package/src/contract/attachment.ts +26 -5
  14. package/src/contract/config.ts +2 -2
  15. package/src/contract/migration.ts +4 -1
  16. package/src/contract/query.ts +6 -6
  17. package/src/crypto/provider.ts +21 -27
  18. package/src/diagnostics/dev-log.ts +28 -4
  19. package/src/diagnostics/errors.ts +17 -16
  20. package/src/diagnostics/redact.ts +5 -23
  21. package/src/migration/executor.ts +27 -6
  22. package/src/migration/ledger.ts +18 -27
  23. package/src/platform/backup-ios.ts +4 -3
  24. package/src/platform/bytes.ts +21 -8
  25. package/src/platform/fs.ts +22 -27
  26. package/src/platform/location.ts +0 -3
  27. package/src/query/budgets.ts +32 -5
  28. package/src/query/cursor.ts +5 -5
  29. package/src/schema/validate-config.ts +3 -53
  30. package/src/sqlite/batch.ts +40 -51
  31. package/src/sqlite/connection.ts +0 -9
  32. package/src/sqlite/native.ts +20 -19
  33. package/src/sqlite/row-codec.ts +5 -2
  34. package/src/sqlite/statements.ts +74 -0
  35. package/src/testing/conformance/attachments.ts +5 -4
  36. package/src/testing/conformance/encryption.ts +3 -2
  37. package/src/testing/conformance/index.ts +1 -1
  38. package/src/testing/index.ts +2 -0
  39. package/src/testing/memory-adapter/attachments.ts +146 -0
  40. package/src/testing/memory-adapter/index.ts +13 -110
  41. package/src/testing/memory-adapter/store.ts +3 -3
@@ -21,7 +21,7 @@
21
21
 
22
22
  import type { DatabaseConfig } from '../contract/config';
23
23
  import { DiagnosticCodes } from '../contract/errors';
24
- import type { MigrationContext } from '../contract/migration';
24
+ import type { MigrationContext, SqlParameter } from '../contract/migration';
25
25
  import type { StorableRecordData } from '../contract/record';
26
26
  import type { CollectionSchema } from '../contract/schema';
27
27
  import type { CollectionOps } from '../contract/transaction';
@@ -29,7 +29,13 @@ import { raise } from '../diagnostics/errors';
29
29
  import { throwIfFaulted } from '../diagnostics/faults';
30
30
  import { createSqlCollection } from '../adapter/collection';
31
31
  import type { Connection, Executor } from '../sqlite/connection';
32
- import { ensureLedger, readSchemaVersion, recordAttempt, writeSchemaVersion } from './ledger';
32
+ import {
33
+ ensureLedger,
34
+ FORMAT_VERSION,
35
+ readVersions,
36
+ recordAttempt,
37
+ writeSchemaVersion,
38
+ } from './ledger';
33
39
  import { pendingMigrations, validateMigrationChain } from './validate-chain';
34
40
 
35
41
  export type MigrationRunContext = {
@@ -44,6 +50,8 @@ export type MigrationRunContext = {
44
50
  * still be holding something usable (FR-050).
45
51
  */
46
52
  closeOnFailure(): Promise<void>;
53
+ /** The bookkeeping tables are known to exist, so creating them is skipped (LDB-07). */
54
+ ledgerReady?: boolean;
47
55
  };
48
56
 
49
57
  function migrationContextFor(context: MigrationRunContext, executor: Executor): MigrationContext {
@@ -66,8 +74,8 @@ function migrationContextFor(context: MigrationRunContext, executor: Executor):
66
74
  message: 'a migration cannot abort its own transaction',
67
75
  });
68
76
  },
69
- async execute(sql: string, params?: unknown[]): Promise<void> {
70
- await executor.execute(sql, params as never);
77
+ async execute(sql: string, params?: SqlParameter[]): Promise<void> {
78
+ await executor.execute(sql, params);
71
79
  },
72
80
  };
73
81
  }
@@ -75,9 +83,22 @@ function migrationContextFor(context: MigrationRunContext, executor: Executor):
75
83
  export async function runMigrations(context: MigrationRunContext): Promise<number> {
76
84
  const { config, connection } = context;
77
85
  validateMigrationChain(config);
78
- await ensureLedger(connection);
86
+ // Skipped when the open already found its bookkeeping tables in place.
87
+ if (context.ledgerReady !== true) {
88
+ await ensureLedger(connection);
89
+ }
90
+
91
+ const { schema: recorded, format } = await readVersions(connection);
79
92
 
80
- const recorded = await readSchemaVersion(connection);
93
+ if (format > FORMAT_VERSION) {
94
+ // A file laid out by a newer release of this package. Reading it with
95
+ // older code is the same hazard as a schema downgrade, one layer down.
96
+ await context.closeOnFailure();
97
+ raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
98
+ message: 'database was written by a newer version of this package',
99
+ detail: { recordedVersion: format, version: FORMAT_VERSION },
100
+ });
101
+ }
81
102
 
82
103
  if (recorded > config.schemaVersion) {
83
104
  // Opening anyway would mean newer data read by older code, which drops
@@ -12,9 +12,9 @@
12
12
  * with a long half-life, sitting in a file the next crash report might carry.
13
13
  */
14
14
 
15
- import type { MigrationLedgerEntry, MigrationOutcome } from '../contract/migration';
15
+ import type { MigrationOutcome } from '../contract/migration';
16
16
  import type { Executor } from '../sqlite/connection';
17
- import { maybeText, num, text } from '../sqlite/values';
17
+ import { text } from '../sqlite/values';
18
18
 
19
19
  /** Physical layout version, independent of the app's schema version. */
20
20
  export const FORMAT_VERSION = 1;
@@ -44,20 +44,23 @@ export async function ensureLedger(executor: Executor): Promise<void> {
44
44
  );
45
45
  }
46
46
 
47
- async function readMeta(executor: Executor, key: string): Promise<string | null> {
48
- const result = await executor.execute('SELECT value FROM _meta WHERE key = ?', [key]);
49
- const row = result.rows[0];
50
- return row ? text(row.value) : null;
51
- }
52
-
53
- export async function readSchemaVersion(executor: Executor): Promise<number> {
54
- const raw = await readMeta(executor, 'schema_version');
55
- return raw === null ? 0 : Number(raw);
56
- }
47
+ export type RecordedVersions = {
48
+ /** The app's schema version this scope has been migrated to. */
49
+ schema: number;
50
+ /** The physical layout this package wrote the file with. */
51
+ format: number;
52
+ };
57
53
 
58
- export async function readFormatVersion(executor: Executor): Promise<number> {
59
- const raw = await readMeta(executor, 'format_version');
60
- return raw === null ? FORMAT_VERSION : Number(raw);
54
+ /** Both versions in one read: every open asks for them. */
55
+ export async function readVersions(executor: Executor): Promise<RecordedVersions> {
56
+ const result = await executor.execute(
57
+ "SELECT key, value FROM _meta WHERE key IN ('schema_version', 'format_version')",
58
+ );
59
+ const values = new Map(result.rows.map(row => [text(row.key), Number(text(row.value))]));
60
+ return {
61
+ schema: values.get('schema_version') ?? 0,
62
+ format: values.get('format_version') ?? FORMAT_VERSION,
63
+ };
61
64
  }
62
65
 
63
66
  export async function writeSchemaVersion(executor: Executor, version: number): Promise<void> {
@@ -81,15 +84,3 @@ export async function recordAttempt(
81
84
  [entry.version, entry.attemptedAt, entry.outcome, entry.failureCode ?? null],
82
85
  );
83
86
  }
84
-
85
- export async function readLedger(executor: Executor): Promise<MigrationLedgerEntry[]> {
86
- const result = await executor.execute(
87
- 'SELECT version, attempted_at, outcome, failure_code FROM _migrations ORDER BY version ASC',
88
- );
89
- return result.rows.map(row => ({
90
- version: num(row.version),
91
- attemptedAt: num(row.attempted_at),
92
- outcome: text(row.outcome) as MigrationOutcome,
93
- ...(maybeText(row.failure_code) === null ? {} : { failureCode: text(row.failure_code) }),
94
- }));
95
- }
@@ -32,9 +32,10 @@ export async function ensureManagedRoot(
32
32
  layout: StorageLayout,
33
33
  includeInPlatformBackup: boolean,
34
34
  ): Promise<void> {
35
- await fileSystem().mkdir(layout.root, {
36
- NSURLIsExcludedFromBackupKey: isIos() ? !includeInPlatformBackup : undefined,
37
- });
35
+ await fileSystem().mkdir(
36
+ layout.root,
37
+ isIos() ? { NSURLIsExcludedFromBackupKey: !includeInPlatformBackup } : {},
38
+ );
38
39
  }
39
40
 
40
41
  /** What the package can state about iOS, where it enforces rather than reports. */
@@ -21,16 +21,29 @@ const LOOKUP = (() => {
21
21
  return table;
22
22
  })();
23
23
 
24
+ /**
25
+ * Codecs Hermes and Node provide but React Native's type declarations omit.
26
+ * Read through this view so the package type-checks without DOM or Node types.
27
+ */
28
+ interface HostCodecs {
29
+ atob?: (data: string) => string;
30
+ btoa?: (data: string) => string;
31
+ TextEncoder?: new () => { encode(input: string): Uint8Array };
32
+ TextDecoder?: new () => { decode(input: Uint8Array): string };
33
+ }
34
+
35
+ export const host = globalThis as unknown as HostCodecs;
36
+
24
37
  /* eslint-disable no-bitwise -- base64 is bit packing; there is no clearer way to write it. */
25
38
 
26
39
  /** Characters per `String.fromCharCode` call; large spreads overflow the stack. */
27
40
  const CHUNK = 0x8000;
28
41
 
29
42
  export function base64ToBytes(base64: string): Uint8Array {
30
- if (typeof globalThis.atob === 'function') {
43
+ if (host.atob) {
31
44
  let binary: string | undefined;
32
45
  try {
33
- binary = globalThis.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
46
+ binary = host.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
34
47
  } catch {
35
48
  // atob is strict where Buffer was lenient; fall through to the
36
49
  // tolerant decoder rather than turn stray characters into a crash.
@@ -63,12 +76,12 @@ export function base64ToBytes(base64: string): Uint8Array {
63
76
  }
64
77
 
65
78
  export function bytesToBase64(bytes: Uint8Array): string {
66
- if (typeof globalThis.btoa === 'function') {
79
+ if (host.btoa) {
67
80
  const parts: string[] = [];
68
81
  for (let index = 0; index < bytes.length; index += CHUNK) {
69
82
  parts.push(String.fromCharCode(...bytes.subarray(index, index + CHUNK)));
70
83
  }
71
- return globalThis.btoa(parts.join(''));
84
+ return host.btoa(parts.join(''));
72
85
  }
73
86
  const parts: string[] = [];
74
87
  for (let start = 0; start < bytes.length; start += CHUNK * 3) {
@@ -98,8 +111,8 @@ export function bytesToBase64(bytes: Uint8Array): string {
98
111
 
99
112
  /** UTF-8 encoding. `TextEncoder` is in Hermes and Node; the loop is the fallback. */
100
113
  export function utf8Bytes(value: string): Uint8Array {
101
- if (typeof TextEncoder !== 'undefined') {
102
- return new TextEncoder().encode(value);
114
+ if (host.TextEncoder) {
115
+ return new host.TextEncoder().encode(value);
103
116
  }
104
117
  const encoded = unescape(encodeURIComponent(value));
105
118
  const bytes = new Uint8Array(encoded.length);
@@ -111,8 +124,8 @@ export function utf8Bytes(value: string): Uint8Array {
111
124
 
112
125
  /** UTF-8 decoding, the inverse of `utf8Bytes`. */
113
126
  export function utf8String(bytes: Uint8Array): string {
114
- if (typeof TextDecoder !== 'undefined') {
115
- return new TextDecoder().decode(bytes);
127
+ if (host.TextDecoder) {
128
+ return new host.TextDecoder().decode(bytes);
116
129
  }
117
130
  let binary = '';
118
131
  for (const byte of bytes) {
@@ -8,8 +8,10 @@
8
8
  * rule the ESLint config enforces.
9
9
  */
10
10
 
11
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
12
+
11
13
  import { DiagnosticCodes } from '../contract/errors';
12
- import { raise } from '../diagnostics/errors';
14
+ import { StorageError } from '../diagnostics/errors';
13
15
 
14
16
  export type FileStat = {
15
17
  path: string;
@@ -38,44 +40,37 @@ export type FileSystem = {
38
40
  readFile(path: string, encoding?: string): Promise<string>;
39
41
  /** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
40
42
  read(path: string, length: number, position: number, encoding?: string): Promise<string>;
43
+ /** One native call, no bytes through JS: how an unencrypted file becomes an artifact. */
44
+ copyFile(from: string, into: string): Promise<void>;
41
45
  stat(path: string): Promise<FileStat>;
42
46
  readdir(path: string): Promise<string[]>;
43
47
  unlink(path: string): Promise<void>;
44
48
  exists(path: string): Promise<boolean>;
45
49
  };
46
50
 
47
- let override: FileSystem | null = null;
48
- let cached: FileSystem | null = null;
51
+ /** Loaded on first use (rn-core's lazy peer); a missing peer is `STORAGE_ACCESS`. */
52
+ const fs = lazyPeer('@dr.pogodin/react-native-fs', () => {
53
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
54
+ try {
55
+ return require('@dr.pogodin/react-native-fs') as FileSystem;
56
+ } catch (cause) {
57
+ throw cause;
58
+ }
59
+ });
49
60
 
50
- /** Swap the peer. Tests and the demo app's in-memory mode only. */
61
+ /** Swap the peer. Tests and the demo app's in-memory mode only; `null` restores it. */
51
62
  export function setFileSystem(module: FileSystem | null): void {
52
- override = module;
53
- cached = null;
63
+ fs.override(module ?? undefined);
54
64
  }
55
65
 
56
66
  export function fileSystem(): FileSystem {
57
- if (override) {
58
- return override;
59
- }
60
- if (!cached) {
61
- try {
62
- cached = require('@dr.pogodin/react-native-fs') as FileSystem;
63
- } catch (error) {
64
- raise(DiagnosticCodes.STORAGE_ACCESS, {
67
+ return fs.require(
68
+ (_peer, cause) =>
69
+ new StorageError(DiagnosticCodes.STORAGE_ACCESS, {
65
70
  message: 'filesystem peer is not installed',
66
- cause: error,
67
- });
68
- }
69
- }
70
- return cached!;
71
- }
72
-
73
- export async function pathExists(path: string): Promise<boolean> {
74
- try {
75
- return await fileSystem().exists(path);
76
- } catch {
77
- return false;
78
- }
71
+ cause,
72
+ }),
73
+ );
79
74
  }
80
75
 
81
76
  /** Remove a path, treating "already gone" as success (FR-062). */
@@ -18,7 +18,6 @@ export const CATALOG_FILE = 'catalog.db';
18
18
  export const SCOPES_DIR = 'scopes';
19
19
  export const DATA_FILE = 'data.db';
20
20
  export const ARTIFACTS_DIR = 'artifacts';
21
- export const MARKER_FILE = '.localdb-marker';
22
21
 
23
22
  /** Normalize separators and collapse redundant segments. No `..` survives. */
24
23
  export function normalizePath(path: string): string {
@@ -46,7 +45,6 @@ export type StorageLayout = {
46
45
  scopeDataPath(scopeKey: string): string;
47
46
  scopeArtifactsPath(scopeKey: string): string;
48
47
  artifactPath(scopeKey: string, attachmentId: string): string;
49
- markerPath: string;
50
48
  };
51
49
 
52
50
  const SCOPE_KEY = /^[0-9a-f]{64}$/;
@@ -93,7 +91,6 @@ export function createLayout(documentDirectory: string, databaseName: string): S
93
91
  return {
94
92
  root,
95
93
  catalogPath: `${root}/${CATALOG_FILE}`,
96
- markerPath: `${root}/${MARKER_FILE}`,
97
94
  scopePath,
98
95
  scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
96
  scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
@@ -10,16 +10,42 @@ import { Budgets } from '../contract/config';
10
10
  import { DiagnosticCodes } from '../contract/errors';
11
11
  import type { StorableRecordData } from '../contract/record';
12
12
  import { raise } from '../diagnostics/errors';
13
- import { utf8Bytes } from '../platform/bytes';
13
+
14
+ /**
15
+ * UTF-8 length of a string, counted rather than encoded: measuring a 256 KB
16
+ * record used to allocate a 256 KB byte array just to read its length.
17
+ */
18
+ export function utf8Length(value: string): number {
19
+ let bytes = 0;
20
+ for (let index = 0; index < value.length; index += 1) {
21
+ const unit = value.charCodeAt(index);
22
+ if (unit < 0x80) {
23
+ bytes += 1;
24
+ } else if (unit < 0x800) {
25
+ bytes += 2;
26
+ } else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
27
+ // A surrogate pair is one four-byte code point.
28
+ bytes += 4;
29
+ index += 1;
30
+ } else {
31
+ bytes += 3;
32
+ }
33
+ }
34
+ return bytes;
35
+ }
14
36
 
15
37
  /** UTF-8 byte length of the serialized payload — what actually hits disk. */
16
38
  export function serializedSize(data: StorableRecordData): number {
17
- const json = JSON.stringify(data ?? {});
18
- return utf8Bytes(json).length;
39
+ return utf8Length(JSON.stringify(data ?? {}));
19
40
  }
20
41
 
21
- export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
22
- const size = serializedSize(data);
42
+ /**
43
+ * Checks the size budget and returns the serialized payload, so the write that
44
+ * follows stores this string instead of serializing the record a second time.
45
+ */
46
+ export function assertRecordWithinBudget(collection: string, data: StorableRecordData): string {
47
+ const payload = JSON.stringify(data ?? {});
48
+ const size = utf8Length(payload);
23
49
  if (size > Budgets.MAX_RECORD_BYTES) {
24
50
  // Truncating would be silent data loss; failing opaquely would leave
25
51
  // the consumer guessing. The size and the limit are both safe to
@@ -29,6 +55,7 @@ export function assertRecordWithinBudget(collection: string, data: StorableRecor
29
55
  detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
30
56
  });
31
57
  }
58
+ return payload;
32
59
  }
33
60
 
34
61
  export function assertBatchWithinBudget(count: number): void {
@@ -14,7 +14,7 @@ import type { StoredRecord } from '../contract/record';
14
14
  import { raise } from '../diagnostics/errors';
15
15
  import { compareValues } from './compare';
16
16
  import { sortValueOf } from './sort';
17
- import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../platform/bytes';
17
+ import { base64ToBytes, bytesToBase64, host, utf8Bytes, utf8String } from '../platform/bytes';
18
18
 
19
19
  export type CursorPayload = {
20
20
  /** Sort key fields and directions, so a cursor cannot be reused across shapes. */
@@ -28,15 +28,15 @@ function shapeOf(sort: SortKey[]): string {
28
28
  }
29
29
 
30
30
  function toBase64(value: string): string {
31
- if (typeof globalThis.btoa === 'function') {
32
- return globalThis.btoa(unescape(encodeURIComponent(value)));
31
+ if (host.btoa) {
32
+ return host.btoa(unescape(encodeURIComponent(value)));
33
33
  }
34
34
  return bytesToBase64(utf8Bytes(value));
35
35
  }
36
36
 
37
37
  function fromBase64(value: string): string {
38
- if (typeof globalThis.atob === 'function') {
39
- return decodeURIComponent(escape(globalThis.atob(value)));
38
+ if (host.atob) {
39
+ return decodeURIComponent(escape(host.atob(value)));
40
40
  }
41
41
  return utf8String(base64ToBytes(value));
42
42
  }
@@ -11,6 +11,7 @@ import { Budgets, type DatabaseConfig } from '../contract/config';
11
11
  import { DiagnosticCodes } from '../contract/errors';
12
12
  import type { CollectionSchema } from '../contract/schema';
13
13
  import { raise } from '../diagnostics/errors';
14
+ import { validateMigrationChain } from '../migration/validate-chain';
14
15
  import { validateDescriptor } from '../scope/validate';
15
16
 
16
17
  const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,62}$/;
@@ -71,58 +72,6 @@ function validateCollection(collection: CollectionSchema, seen: Set<string>): vo
71
72
  }
72
73
  }
73
74
 
74
- function validateMigrations(config: DatabaseConfig): void {
75
- const migrations = config.migrations ?? [];
76
- if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
77
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
78
- message: 'schemaVersion must be a positive integer',
79
- });
80
- }
81
-
82
- const versions = new Set<number>();
83
- for (const migration of migrations) {
84
- if (!Number.isInteger(migration.version) || migration.version < 1) {
85
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
86
- message: 'migration version must be a positive integer',
87
- });
88
- }
89
- if (versions.has(migration.version)) {
90
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
91
- message: 'duplicate migration version',
92
- detail: { version: migration.version },
93
- });
94
- }
95
- versions.add(migration.version);
96
- if (typeof migration.migrate !== 'function') {
97
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
98
- message: 'migration has no migrate function',
99
- detail: { version: migration.version },
100
- });
101
- }
102
- }
103
-
104
- // A chain must be able to carry a database from 1 to schemaVersion. A gap
105
- // means some upgrading user has no path across it, which is worse than
106
- // refusing to open.
107
- for (let version = 1; version <= config.schemaVersion; version += 1) {
108
- if (!versions.has(version)) {
109
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
110
- message: 'migration chain has a gap',
111
- detail: { version },
112
- });
113
- }
114
- }
115
-
116
- for (const version of versions) {
117
- if (version > config.schemaVersion) {
118
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
119
- message: 'migration version exceeds the declared schemaVersion',
120
- detail: { version },
121
- });
122
- }
123
- }
124
- }
125
-
126
75
  export function validateConfig(config: DatabaseConfig): void {
127
76
  if (!config || typeof config !== 'object') {
128
77
  configInvalid('database config is required');
@@ -148,7 +97,8 @@ export function validateConfig(config: DatabaseConfig): void {
148
97
  validateCollection(collection, seen);
149
98
  }
150
99
 
151
- validateMigrations(config);
100
+ // The one definition of a valid chain, shared with the executor (LDB-12).
101
+ validateMigrationChain(config);
152
102
 
153
103
  if (config.encryption && config.encryption.enabled) {
154
104
  if (typeof config.encryption.keyProvider?.getKey !== 'function') {
@@ -21,8 +21,7 @@ import { raise } from '../diagnostics/errors';
21
21
  import { assertBatchWithinBudget, assertRecordWithinBudget } from '../query/budgets';
22
22
  import { collectionTable, hasSoftDelete } from '../schema/declare';
23
23
  import type { Connection } from './connection';
24
- import type { SqlValue } from './native';
25
- import { insertColumns, updateAssignments } from './row-codec';
24
+ import { insertStatement, updateStatement, type Statement } from './statements';
26
25
 
27
26
  export type BatchContext = {
28
27
  connection: Connection;
@@ -31,42 +30,12 @@ export type BatchContext = {
31
30
  now(): number;
32
31
  };
33
32
 
34
- type Command = [string, SqlValue[]];
33
+ type Command = Statement;
35
34
 
36
35
  function quoted(schema: CollectionSchema): string {
37
36
  return `"${collectionTable(schema.name)}"`;
38
37
  }
39
38
 
40
- function insertCommand(
41
- schema: CollectionSchema,
42
- id: string,
43
- data: Parameters<typeof insertColumns>[2],
44
- now: number,
45
- ): Command {
46
- const { columns, values } = insertColumns(schema, id, data, now);
47
- return [
48
- `INSERT INTO ${quoted(schema)} (${columns
49
- .map(name => `"${name}"`)
50
- .join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
51
- values,
52
- ];
53
- }
54
-
55
- function updateCommand(
56
- schema: CollectionSchema,
57
- id: string,
58
- data: Parameters<typeof updateAssignments>[1],
59
- now: number,
60
- restore: boolean,
61
- ): Command {
62
- const { columns, values } = updateAssignments(schema, data, now);
63
- const assignments = columns.map(name => `"${name}" = ?`);
64
- if (restore && hasSoftDelete(schema)) {
65
- assignments.push('deleted_at = NULL');
66
- }
67
- return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
- }
69
-
70
39
  /**
71
40
  * Where a record stands, as this batch has left it so far. Three states, not
72
41
  * two: a soft-deleted row is invisible to reads but still holds its primary
@@ -104,13 +73,13 @@ async function resolveInsert(
104
73
  operation: Extract<BatchOperation, { type: 'insert' }>,
105
74
  ): Promise<Resolved> {
106
75
  const { schema, now } = resolver;
107
- assertRecordWithinBudget(schema.name, operation.record.data);
76
+ const payload = assertRecordWithinBudget(schema.name, operation.record.data);
108
77
  if ((await resolver.state(operation.record.id)) !== 'absent') {
109
78
  conflict(schema, operation.record.id);
110
79
  }
111
80
  resolver.stage(operation.record.id, 'live');
112
81
  return {
113
- command: insertCommand(schema, operation.record.id, operation.record.data, now),
82
+ command: insertStatement(schema, operation.record.id, operation.record.data, now, payload),
114
83
  outcome: { operation: 'created', id: operation.record.id },
115
84
  };
116
85
  }
@@ -120,12 +89,14 @@ async function resolveUpdate(
120
89
  operation: Extract<BatchOperation, { type: 'update' }>,
121
90
  ): Promise<Resolved> {
122
91
  const { schema, now } = resolver;
123
- assertRecordWithinBudget(schema.name, operation.data);
92
+ const payload = assertRecordWithinBudget(schema.name, operation.data);
124
93
  if ((await resolver.state(operation.id)) !== 'live') {
125
94
  missing(schema, operation.id);
126
95
  }
127
96
  return {
128
- command: updateCommand(schema, operation.id, operation.data, now, false),
97
+ command: updateStatement(schema, operation.id, operation.data, now, payload, {
98
+ restore: false,
99
+ }),
129
100
  outcome: { operation: 'updated', id: operation.id },
130
101
  };
131
102
  }
@@ -135,13 +106,15 @@ async function resolveUpsert(
135
106
  operation: Extract<BatchOperation, { type: 'upsert' }>,
136
107
  ): Promise<Resolved> {
137
108
  const { schema, now } = resolver;
138
- assertRecordWithinBudget(schema.name, operation.record.data);
109
+ const payload = assertRecordWithinBudget(schema.name, operation.record.data);
139
110
  const present = (await resolver.state(operation.record.id)) !== 'absent';
140
111
  resolver.stage(operation.record.id, 'live');
141
112
  return {
142
113
  command: present
143
- ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
144
- : insertCommand(schema, operation.record.id, operation.record.data, now),
114
+ ? updateStatement(schema, operation.record.id, operation.record.data, now, payload, {
115
+ restore: true,
116
+ })
117
+ : insertStatement(schema, operation.record.id, operation.record.data, now, payload),
145
118
  outcome: { operation: present ? 'updated' : 'created', id: operation.record.id },
146
119
  };
147
120
  }
@@ -209,23 +182,39 @@ export async function runBatch(
209
182
  const outcomes: WriteOutcome[] = [];
210
183
  const overlay = new Map<string, RowState>();
211
184
 
185
+ // What the batch will touch, read once per collection (LDB-08). A batch of a
186
+ // thousand operations used to make a thousand SELECTs before its one write.
187
+ // The ids travel as one JSON parameter, so no variable limit applies.
188
+ const known = new Map<string, RowState>();
189
+ const idsByCollection = new Map<string, Set<string>>();
190
+ for (const operation of operations) {
191
+ const id = 'record' in operation ? operation.record.id : operation.id;
192
+ const ids = idsByCollection.get(operation.collection) ?? new Set<string>();
193
+ ids.add(id);
194
+ idsByCollection.set(operation.collection, ids);
195
+ }
196
+ for (const [collection, ids] of idsByCollection) {
197
+ const schema = context.schemaFor(collection);
198
+ const result = await context.connection.execute(
199
+ `SELECT id, deleted_at FROM ${quoted(schema)} WHERE id IN (SELECT value FROM json_each(?))`,
200
+ [JSON.stringify([...ids])],
201
+ );
202
+ for (const row of result.rows) {
203
+ known.set(
204
+ `${schema.name}:${String(row.id)}`,
205
+ row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted',
206
+ );
207
+ }
208
+ }
209
+
212
210
  for (const operation of operations) {
213
211
  const schema = context.schemaFor(operation.collection);
214
212
  const resolver: Resolver = {
215
213
  schema,
216
214
  now: context.now(),
217
215
  async state(id) {
218
- const staged = overlay.get(`${schema.name}:${id}`);
219
- if (staged !== undefined) {
220
- return staged;
221
- }
222
- const result = await context.connection.execute(
223
- `SELECT deleted_at FROM ${quoted(schema)} WHERE id = ?`,
224
- [id],
225
- );
226
- const row = result.rows[0];
227
- if (!row) return 'absent';
228
- return row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted';
216
+ const key = `${schema.name}:${id}`;
217
+ return overlay.get(key) ?? known.get(key) ?? 'absent';
229
218
  },
230
219
  stage(id, state) {
231
220
  overlay.set(`${schema.name}:${id}`, state);
@@ -52,8 +52,6 @@ export type Connection = Executor & {
52
52
  readonly encrypted: boolean;
53
53
  transaction<R>(fn: (tx: TransactionExecutor) => Promise<R>): Promise<R>;
54
54
  executeBatch(commands: [string, SqlValue[]?][]): Promise<void>;
55
- /** Full-database check, for the diagnostics surface. Never run at open. */
56
- integrityCheck(): Promise<boolean>;
57
55
  close(): Promise<void>;
58
56
  };
59
57
 
@@ -180,13 +178,6 @@ export async function openConnection(options: OpenConnectionOptions): Promise<Co
180
178
  });
181
179
  },
182
180
 
183
- async integrityCheck() {
184
- assertOpen();
185
- const result = await connection.execute('PRAGMA quick_check(1)');
186
- const first = result.rows[0];
187
- return first ? Object.values(first)[0] === 'ok' : false;
188
- },
189
-
190
181
  async close() {
191
182
  if (closed) {
192
183
  return;