@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1

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 (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
package/src/index.ts CHANGED
@@ -1,110 +1,80 @@
1
- /**
2
- * `@chipmobilesdk/rn-local-db`
3
- *
4
- * Offline-first, scope-isolated local storage for ChipMobileSdk apps.
5
- *
6
- * What is deliberately absent is part of the contract: there is no
7
- * `switchScope`, and no read, write, or query operation accepts a scope
8
- * argument. Scope is bound to a handle at open time and immutable, so a
9
- * cross-scope leak is not expressible rather than merely prevented.
10
- *
11
- * The underlying engine, the physical layout, key material, and the query
12
- * translation are private and unreachable by deep import.
13
- */
14
-
15
- // Lifecycle
16
- export {
17
- deleteDatabase,
18
- deleteScopeData,
19
- openDatabase,
20
- resetScope,
21
- } from './adapter';
22
-
23
- // Diagnostics
24
- export {
25
- DiagnosticCodes,
26
- StorageError,
27
- isCode,
28
- isStorageError,
29
- } from './diagnostics/errors';
30
- export { setDiagnosticLogger, type DiagnosticLogger } from './diagnostics/dev-log';
31
-
32
- // Budgets, so a consumer can check before it writes rather than after
33
- export { Budgets } from './contract/config';
34
-
35
- // Contract types
36
- export type {
37
- DatabaseConfig,
38
- DatabaseTargetConfig,
39
- ScopeTargetConfig,
40
- } from './contract/config';
41
- export type {
42
- ResolvedScope,
43
- ScopeDescriptor,
44
- ScopeKind,
45
- } from './contract/scope';
46
- export type {
47
- CollectionSchema,
48
- FieldDeclaration,
49
- FieldType,
50
- IndexDeclaration,
51
- } from './contract/schema';
52
- export type {
53
- RecordInput,
54
- StorableRecordData,
55
- StorableValue,
56
- StoredRecord,
57
- WriteOperation,
58
- WriteOutcome,
59
- } from './contract/record';
60
- export type {
61
- ComparisonNode,
62
- FilterNode,
63
- FilterValue,
64
- IndexCoverageReport,
65
- LogicalNode,
66
- PageRequest,
67
- Query,
68
- ResultPage,
69
- SortDirection,
70
- SortKey,
71
- } from './contract/query';
72
- export type {
73
- BatchOperation,
74
- BatchOutcome,
75
- CollectionOps,
76
- Transaction,
77
- } from './contract/transaction';
78
- export type {
79
- Migration,
80
- MigrationContext,
81
- MigrationFailureDetail,
82
- MigrationLedgerEntry,
83
- MigrationOutcome,
84
- } from './contract/migration';
85
- export type {
86
- AttachmentAvailability,
87
- AttachmentAvailabilityState,
88
- AttachmentInput,
89
- AttachmentOwner,
90
- AttachmentReference,
91
- ByteRange,
92
- DeletionOutcome,
93
- ReconciliationReport,
94
- } from './contract/attachment';
95
- export type {
96
- EncryptionConfig,
97
- EncryptionPosture,
98
- KeyProvider,
99
- } from './contract/encryption';
100
- export type {
101
- BackupConfig,
102
- BackupPostureValue,
103
- EffectivePosture,
104
- } from './contract/backup';
105
- export type { DiagnosticCode } from './contract/errors';
106
- export type {
107
- Attachments,
108
- DatabaseHandle,
109
- StorageAdapter,
110
- } from './contract/adapter';
1
+ /**
2
+ * `@chipmobilesdk/rn-local-db`
3
+ *
4
+ * Offline-first, scope-isolated local storage for ChipMobileSdk apps.
5
+ *
6
+ * What is deliberately absent is part of the contract: there is no
7
+ * `switchScope`, and no read, write, or query operation accepts a scope
8
+ * argument. Scope is bound to a handle at open time and immutable, so a
9
+ * cross-scope leak is not expressible rather than merely prevented.
10
+ *
11
+ * The underlying engine, the physical layout, key material, and the query
12
+ * translation are private and unreachable by deep import.
13
+ */
14
+
15
+ // Lifecycle
16
+ export { deleteDatabase, deleteScopeData, openDatabase, resetScope } from './adapter';
17
+
18
+ // Diagnostics
19
+ export { DiagnosticCodes, StorageError, isCode, isStorageError } from './diagnostics/errors';
20
+ export { setDiagnosticLogger, type DiagnosticLogger } from './diagnostics/dev-log';
21
+
22
+ // Budgets, so a consumer can check before it writes rather than after
23
+ export { Budgets } from './contract/config';
24
+
25
+ // Contract types
26
+ export type { DatabaseConfig, DatabaseTargetConfig, ScopeTargetConfig } from './contract/config';
27
+ export type { ResolvedScope, ScopeDescriptor, ScopeKind } from './contract/scope';
28
+ export type {
29
+ CollectionSchema,
30
+ FieldDeclaration,
31
+ FieldType,
32
+ IndexDeclaration,
33
+ } from './contract/schema';
34
+ export type {
35
+ RecordInput,
36
+ StorableRecordData,
37
+ StorableValue,
38
+ StoredRecord,
39
+ WriteOperation,
40
+ WriteOutcome,
41
+ } from './contract/record';
42
+ export type {
43
+ ComparisonNode,
44
+ FilterNode,
45
+ FilterValue,
46
+ IndexCoverageReport,
47
+ LogicalNode,
48
+ PageRequest,
49
+ Query,
50
+ ResultPage,
51
+ SortDirection,
52
+ SortKey,
53
+ } from './contract/query';
54
+ export type {
55
+ BatchOperation,
56
+ BatchOutcome,
57
+ CollectionOps,
58
+ Transaction,
59
+ } from './contract/transaction';
60
+ export type {
61
+ Migration,
62
+ MigrationContext,
63
+ MigrationFailureDetail,
64
+ MigrationLedgerEntry,
65
+ MigrationOutcome,
66
+ } from './contract/migration';
67
+ export type {
68
+ AttachmentAvailability,
69
+ AttachmentAvailabilityState,
70
+ AttachmentInput,
71
+ AttachmentOwner,
72
+ AttachmentReference,
73
+ ByteRange,
74
+ DeletionOutcome,
75
+ ReconciliationReport,
76
+ } from './contract/attachment';
77
+ export type { EncryptionConfig, EncryptionPosture, KeyProvider } from './contract/encryption';
78
+ export type { BackupConfig, BackupPostureValue, EffectivePosture } from './contract/backup';
79
+ export type { DiagnosticCode } from './contract/errors';
80
+ export type { Attachments, DatabaseHandle, StorageAdapter } from './contract/adapter';
@@ -1,136 +1,132 @@
1
- /**
2
- * The migration executor.
3
- *
4
- * Four rules, each of which exists because the alternative loses data:
5
- *
6
- * - **Ascending, and pending only.** A migration whose version is at or below
7
- * the recorded one has already run against this scope (FR-047, FR-049).
8
- * - **One transaction per migration.** A failure unwinds that migration and
9
- * nothing else, so the recorded version and the data always agree.
10
- * - **The recorded version moves only on success**, inside the same
11
- * transaction as the migration's own writes. Writing it separately would
12
- * leave a window where a crash records a migration that did not happen.
13
- * - **A failure returns no handle** (FR-050). Half-migrated data behind a
14
- * working handle is the failure this whole design exists to prevent, and it
15
- * is worse than being unable to open at all: the app writes to it.
16
- *
17
- * A retry starts from the same version rather than skipping ahead (FR-051),
18
- * which is what makes a transient failure — a locked keychain, a full disk
19
- * mid-migration — recoverable on the next launch instead of permanent.
20
- */
21
-
22
- import type { DatabaseConfig } from '../contract/config';
23
- import { DiagnosticCodes } from '../contract/errors';
24
- import type { MigrationContext } from '../contract/migration';
25
- import type { StorableRecordData } from '../contract/record';
26
- import type { CollectionSchema } from '../contract/schema';
27
- import type { CollectionOps } from '../contract/transaction';
28
- import { raise } from '../diagnostics/errors';
29
- import { throwIfFaulted } from '../diagnostics/faults';
30
- import { createSqlCollection } from '../adapter/collection';
31
- import type { Connection, Executor } from '../sqlite/connection';
32
- import { ensureLedger, readSchemaVersion, recordAttempt, writeSchemaVersion } from './ledger';
33
- import { pendingMigrations, validateMigrationChain } from './validate-chain';
34
-
35
- export type MigrationRunContext = {
36
- connection: Connection;
37
- config: DatabaseConfig;
38
- schemaFor(name: string): CollectionSchema;
39
- now(): number;
40
- /**
41
- * Called before a migration failure is raised. The executor does not own
42
- * the connection, so it asks rather than closing directly — but the close
43
- * has to happen before the error escapes, or a caller catching it could
44
- * still be holding something usable (FR-050).
45
- */
46
- closeOnFailure(): Promise<void>;
47
- };
48
-
49
- function migrationContextFor(
50
- context: MigrationRunContext,
51
- executor: Executor,
52
- ): MigrationContext {
53
- return {
54
- collection<T extends StorableRecordData>(name: string): CollectionOps<T> {
55
- return createSqlCollection<T>({
56
- executor: () => executor,
57
- schema: context.schemaFor(name),
58
- now: context.now,
59
- // Inside a migration the handle does not exist yet, so there is
60
- // no closed-handle or deleted-scope state to guard against.
61
- assertUsable: () => {},
62
- });
63
- },
64
- async abort(): Promise<void> {
65
- // A migration that wants to give up should throw; aborting the
66
- // transaction from inside while the executor still believes it is
67
- // running would record an outcome that never happened.
68
- raise(DiagnosticCodes.TRANSACTION_STATE, {
69
- message: 'a migration cannot abort its own transaction',
70
- });
71
- },
72
- async execute(sql: string, params?: unknown[]): Promise<void> {
73
- await executor.execute(sql, params as never);
74
- },
75
- };
76
- }
77
-
78
- export async function runMigrations(context: MigrationRunContext): Promise<number> {
79
- const { config, connection } = context;
80
- validateMigrationChain(config);
81
- await ensureLedger(connection);
82
-
83
- const recorded = await readSchemaVersion(connection);
84
-
85
- if (recorded > config.schemaVersion) {
86
- // Opening anyway would mean newer data read by older code, which drops
87
- // whatever the newer schema added — silently, and on write (FR-052).
88
- await context.closeOnFailure();
89
- raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
90
- message: 'recorded schema version is newer than the declared one',
91
- detail: { recordedVersion: recorded, version: config.schemaVersion },
92
- });
93
- }
94
-
95
- let current = recorded;
96
-
97
- for (const migration of pendingMigrations(config, recorded)) {
98
- try {
99
- await connection.transaction(async tx => {
100
- throwIfFaulted('migration');
101
- await migration.migrate(migrationContextFor(context, tx));
102
- await writeSchemaVersion(tx, migration.version);
103
- await recordAttempt(tx, {
104
- version: migration.version,
105
- attemptedAt: context.now(),
106
- outcome: 'applied',
107
- });
108
- });
109
- current = migration.version;
110
- } catch (error) {
111
- // Recorded outside the rolled-back transaction, or it would vanish
112
- // with the migration it describes.
113
- await recordAttempt(connection, {
114
- version: migration.version,
115
- attemptedAt: context.now(),
116
- outcome: 'failed',
117
- failureCode:
118
- (error as { code?: string }).code ?? DiagnosticCodes.MIGRATION_FAILED,
119
- });
120
- await context.closeOnFailure();
121
- raise(DiagnosticCodes.MIGRATION_FAILED, {
122
- message: 'migration failed',
123
- migration: {
124
- // Unchanged: the next open retries from here rather than
125
- // skipping the migration that failed.
126
- recordedVersion: current,
127
- failingVersion: migration.version,
128
- outcome: 'failed',
129
- },
130
- cause: error,
131
- });
132
- }
133
- }
134
-
135
- return current;
136
- }
1
+ /**
2
+ * The migration executor.
3
+ *
4
+ * Four rules, each of which exists because the alternative loses data:
5
+ *
6
+ * - **Ascending, and pending only.** A migration whose version is at or below
7
+ * the recorded one has already run against this scope (FR-047, FR-049).
8
+ * - **One transaction per migration.** A failure unwinds that migration and
9
+ * nothing else, so the recorded version and the data always agree.
10
+ * - **The recorded version moves only on success**, inside the same
11
+ * transaction as the migration's own writes. Writing it separately would
12
+ * leave a window where a crash records a migration that did not happen.
13
+ * - **A failure returns no handle** (FR-050). Half-migrated data behind a
14
+ * working handle is the failure this whole design exists to prevent, and it
15
+ * is worse than being unable to open at all: the app writes to it.
16
+ *
17
+ * A retry starts from the same version rather than skipping ahead (FR-051),
18
+ * which is what makes a transient failure — a locked keychain, a full disk
19
+ * mid-migration — recoverable on the next launch instead of permanent.
20
+ */
21
+
22
+ import type { DatabaseConfig } from '../contract/config';
23
+ import { DiagnosticCodes } from '../contract/errors';
24
+ import type { MigrationContext } from '../contract/migration';
25
+ import type { StorableRecordData } from '../contract/record';
26
+ import type { CollectionSchema } from '../contract/schema';
27
+ import type { CollectionOps } from '../contract/transaction';
28
+ import { raise } from '../diagnostics/errors';
29
+ import { throwIfFaulted } from '../diagnostics/faults';
30
+ import { createSqlCollection } from '../adapter/collection';
31
+ import type { Connection, Executor } from '../sqlite/connection';
32
+ import { ensureLedger, readSchemaVersion, recordAttempt, writeSchemaVersion } from './ledger';
33
+ import { pendingMigrations, validateMigrationChain } from './validate-chain';
34
+
35
+ export type MigrationRunContext = {
36
+ connection: Connection;
37
+ config: DatabaseConfig;
38
+ schemaFor(name: string): CollectionSchema;
39
+ now(): number;
40
+ /**
41
+ * Called before a migration failure is raised. The executor does not own
42
+ * the connection, so it asks rather than closing directly — but the close
43
+ * has to happen before the error escapes, or a caller catching it could
44
+ * still be holding something usable (FR-050).
45
+ */
46
+ closeOnFailure(): Promise<void>;
47
+ };
48
+
49
+ function migrationContextFor(context: MigrationRunContext, executor: Executor): MigrationContext {
50
+ return {
51
+ collection<T extends StorableRecordData>(name: string): CollectionOps<T> {
52
+ return createSqlCollection<T>({
53
+ executor: () => executor,
54
+ schema: context.schemaFor(name),
55
+ now: context.now,
56
+ // Inside a migration the handle does not exist yet, so there is
57
+ // no closed-handle or deleted-scope state to guard against.
58
+ assertUsable: () => {},
59
+ });
60
+ },
61
+ async abort(): Promise<void> {
62
+ // A migration that wants to give up should throw; aborting the
63
+ // transaction from inside while the executor still believes it is
64
+ // running would record an outcome that never happened.
65
+ raise(DiagnosticCodes.TRANSACTION_STATE, {
66
+ message: 'a migration cannot abort its own transaction',
67
+ });
68
+ },
69
+ async execute(sql: string, params?: unknown[]): Promise<void> {
70
+ await executor.execute(sql, params as never);
71
+ },
72
+ };
73
+ }
74
+
75
+ export async function runMigrations(context: MigrationRunContext): Promise<number> {
76
+ const { config, connection } = context;
77
+ validateMigrationChain(config);
78
+ await ensureLedger(connection);
79
+
80
+ const recorded = await readSchemaVersion(connection);
81
+
82
+ if (recorded > config.schemaVersion) {
83
+ // Opening anyway would mean newer data read by older code, which drops
84
+ // whatever the newer schema added — silently, and on write (FR-052).
85
+ await context.closeOnFailure();
86
+ raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
87
+ message: 'recorded schema version is newer than the declared one',
88
+ detail: { recordedVersion: recorded, version: config.schemaVersion },
89
+ });
90
+ }
91
+
92
+ let current = recorded;
93
+
94
+ for (const migration of pendingMigrations(config, recorded)) {
95
+ try {
96
+ await connection.transaction(async tx => {
97
+ throwIfFaulted('migration');
98
+ await migration.migrate(migrationContextFor(context, tx));
99
+ await writeSchemaVersion(tx, migration.version);
100
+ await recordAttempt(tx, {
101
+ version: migration.version,
102
+ attemptedAt: context.now(),
103
+ outcome: 'applied',
104
+ });
105
+ });
106
+ current = migration.version;
107
+ } catch (error) {
108
+ // Recorded outside the rolled-back transaction, or it would vanish
109
+ // with the migration it describes.
110
+ await recordAttempt(connection, {
111
+ version: migration.version,
112
+ attemptedAt: context.now(),
113
+ outcome: 'failed',
114
+ failureCode: (error as { code?: string }).code ?? DiagnosticCodes.MIGRATION_FAILED,
115
+ });
116
+ await context.closeOnFailure();
117
+ raise(DiagnosticCodes.MIGRATION_FAILED, {
118
+ message: 'migration failed',
119
+ migration: {
120
+ // Unchanged: the next open retries from here rather than
121
+ // skipping the migration that failed.
122
+ recordedVersion: current,
123
+ failingVersion: migration.version,
124
+ outcome: 'failed',
125
+ },
126
+ cause: error,
127
+ });
128
+ }
129
+ }
130
+
131
+ return current;
132
+ }
@@ -1,97 +1,95 @@
1
- /**
2
- * The per-scope migration ledger.
3
- *
4
- * Per scope, not per database, and that is the whole reason FR-054 works: a
5
- * scope created after a migration shipped starts at version 0 and is migrated
6
- * on first open, rather than being assumed current because some other scope
7
- * already ran the chain.
8
- *
9
- * The ledger records a **code**, never a message (FR-079). A migration that
10
- * failed because a domain constraint was violated could otherwise write the
11
- * offending value into a table that outlives the failure — a payload leak
12
- * with a long half-life, sitting in a file the next crash report might carry.
13
- */
14
-
15
- import type { MigrationLedgerEntry, MigrationOutcome } from '../contract/migration';
16
- import type { Executor } from '../sqlite/connection';
17
- import { maybeText, num, text } from '../sqlite/values';
18
-
19
- /** Physical layout version, independent of the app's schema version. */
20
- export const FORMAT_VERSION = 1;
21
-
22
- const CREATE_META = `CREATE TABLE IF NOT EXISTS _meta (
23
- key TEXT PRIMARY KEY,
24
- value TEXT NOT NULL
25
- )`;
26
-
27
- const CREATE_MIGRATIONS = `CREATE TABLE IF NOT EXISTS _migrations (
28
- version INTEGER PRIMARY KEY,
29
- attempted_at INTEGER NOT NULL,
30
- outcome TEXT NOT NULL,
31
- failure_code TEXT
32
- )`;
33
-
34
- export async function ensureLedger(executor: Executor): Promise<void> {
35
- await executor.execute(CREATE_META);
36
- await executor.execute(CREATE_MIGRATIONS);
37
- await executor.execute(
38
- 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING',
39
- ['format_version', String(FORMAT_VERSION)],
40
- );
41
- await executor.execute(
42
- 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING',
43
- ['schema_version', '0'],
44
- );
45
- }
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
- }
57
-
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);
61
- }
62
-
63
- export async function writeSchemaVersion(executor: Executor, version: number): Promise<void> {
64
- await executor.execute(
65
- 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
66
- ['schema_version', String(version)],
67
- );
68
- }
69
-
70
- export async function recordAttempt(
71
- executor: Executor,
72
- entry: { version: number; attemptedAt: number; outcome: MigrationOutcome; failureCode?: string },
73
- ): Promise<void> {
74
- await executor.execute(
75
- `INSERT INTO _migrations (version, attempted_at, outcome, failure_code)
76
- VALUES (?, ?, ?, ?)
77
- ON CONFLICT(version) DO UPDATE SET
78
- attempted_at = excluded.attempted_at,
79
- outcome = excluded.outcome,
80
- failure_code = excluded.failure_code`,
81
- [entry.version, entry.attemptedAt, entry.outcome, entry.failureCode ?? null],
82
- );
83
- }
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
94
- ? {}
95
- : { failureCode: text(row.failure_code) }),
96
- }));
97
- }
1
+ /**
2
+ * The per-scope migration ledger.
3
+ *
4
+ * Per scope, not per database, and that is the whole reason FR-054 works: a
5
+ * scope created after a migration shipped starts at version 0 and is migrated
6
+ * on first open, rather than being assumed current because some other scope
7
+ * already ran the chain.
8
+ *
9
+ * The ledger records a **code**, never a message (FR-079). A migration that
10
+ * failed because a domain constraint was violated could otherwise write the
11
+ * offending value into a table that outlives the failure — a payload leak
12
+ * with a long half-life, sitting in a file the next crash report might carry.
13
+ */
14
+
15
+ import type { MigrationLedgerEntry, MigrationOutcome } from '../contract/migration';
16
+ import type { Executor } from '../sqlite/connection';
17
+ import { maybeText, num, text } from '../sqlite/values';
18
+
19
+ /** Physical layout version, independent of the app's schema version. */
20
+ export const FORMAT_VERSION = 1;
21
+
22
+ const CREATE_META = `CREATE TABLE IF NOT EXISTS _meta (
23
+ key TEXT PRIMARY KEY,
24
+ value TEXT NOT NULL
25
+ )`;
26
+
27
+ const CREATE_MIGRATIONS = `CREATE TABLE IF NOT EXISTS _migrations (
28
+ version INTEGER PRIMARY KEY,
29
+ attempted_at INTEGER NOT NULL,
30
+ outcome TEXT NOT NULL,
31
+ failure_code TEXT
32
+ )`;
33
+
34
+ export async function ensureLedger(executor: Executor): Promise<void> {
35
+ await executor.execute(CREATE_META);
36
+ await executor.execute(CREATE_MIGRATIONS);
37
+ await executor.execute(
38
+ 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING',
39
+ ['format_version', String(FORMAT_VERSION)],
40
+ );
41
+ await executor.execute(
42
+ 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING',
43
+ ['schema_version', '0'],
44
+ );
45
+ }
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
+ }
57
+
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);
61
+ }
62
+
63
+ export async function writeSchemaVersion(executor: Executor, version: number): Promise<void> {
64
+ await executor.execute(
65
+ 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
66
+ ['schema_version', String(version)],
67
+ );
68
+ }
69
+
70
+ export async function recordAttempt(
71
+ executor: Executor,
72
+ entry: { version: number; attemptedAt: number; outcome: MigrationOutcome; failureCode?: string },
73
+ ): Promise<void> {
74
+ await executor.execute(
75
+ `INSERT INTO _migrations (version, attempted_at, outcome, failure_code)
76
+ VALUES (?, ?, ?, ?)
77
+ ON CONFLICT(version) DO UPDATE SET
78
+ attempted_at = excluded.attempted_at,
79
+ outcome = excluded.outcome,
80
+ failure_code = excluded.failure_code`,
81
+ [entry.version, entry.attemptedAt, entry.outcome, entry.failureCode ?? null],
82
+ );
83
+ }
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
+ }