@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.
- package/CHANGELOG.md +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} from './contract/
|
|
60
|
-
export type {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} from './contract/
|
|
78
|
-
export type {
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
package/src/migration/ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|