@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.
- package/CHANGELOG.md +92 -1
- package/README.md +31 -12
- package/package.json +5 -2
- package/skills/sdk-rn-local-db/SKILL.md +1 -1
- package/src/adapter/budgets.ts +1 -10
- package/src/adapter/collection.ts +38 -66
- package/src/adapter/deletion.ts +48 -0
- package/src/adapter/handle.ts +311 -0
- package/src/adapter/lifecycle.ts +10 -338
- package/src/adapter/schema-sync.ts +155 -0
- package/src/artifacts/cascade.ts +0 -14
- package/src/artifacts/store.ts +60 -10
- package/src/contract/attachment.ts +26 -5
- package/src/contract/config.ts +2 -2
- package/src/contract/migration.ts +4 -1
- package/src/contract/query.ts +6 -6
- package/src/crypto/provider.ts +21 -27
- package/src/diagnostics/dev-log.ts +28 -4
- package/src/diagnostics/errors.ts +17 -16
- package/src/diagnostics/redact.ts +5 -23
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/backup-ios.ts +4 -3
- package/src/platform/bytes.ts +21 -8
- package/src/platform/fs.ts +22 -27
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -5
- package/src/query/cursor.ts +5 -5
- package/src/schema/validate-config.ts +3 -53
- package/src/sqlite/batch.ts +40 -51
- package/src/sqlite/connection.ts +0 -9
- package/src/sqlite/native.ts +20 -19
- package/src/sqlite/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/conformance/attachments.ts +5 -4
- package/src/testing/conformance/encryption.ts +3 -2
- package/src/testing/conformance/index.ts +1 -1
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -110
- 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 {
|
|
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?:
|
|
70
|
-
await executor.execute(sql, params
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/migration/ledger.ts
CHANGED
|
@@ -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 {
|
|
15
|
+
import type { MigrationOutcome } from '../contract/migration';
|
|
16
16
|
import type { Executor } from '../sqlite/connection';
|
|
17
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
36
|
-
|
|
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. */
|
package/src/platform/bytes.ts
CHANGED
|
@@ -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 (
|
|
43
|
+
if (host.atob) {
|
|
31
44
|
let binary: string | undefined;
|
|
32
45
|
try {
|
|
33
|
-
binary =
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (
|
|
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) {
|
package/src/platform/fs.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
cached = null;
|
|
63
|
+
fs.override(module ?? undefined);
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
export function fileSystem(): FileSystem {
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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). */
|
package/src/platform/location.ts
CHANGED
|
@@ -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}`,
|
package/src/query/budgets.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
return utf8Bytes(json).length;
|
|
39
|
+
return utf8Length(JSON.stringify(data ?? {}));
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
package/src/query/cursor.ts
CHANGED
|
@@ -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 (
|
|
32
|
-
return
|
|
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 (
|
|
39
|
-
return decodeURIComponent(escape(
|
|
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
|
-
|
|
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') {
|
package/src/sqlite/batch.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
144
|
-
|
|
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
|
|
219
|
-
|
|
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);
|
package/src/sqlite/connection.ts
CHANGED
|
@@ -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;
|