@chipmobilesdk/rn-local-db 0.2.0 → 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 -102
- package/README.md +728 -728
- 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 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- 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 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- 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 -144
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- 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 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- 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 -492
- 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/sqlite/catalog.ts
CHANGED
|
@@ -1,108 +1,105 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The catalog: one registry per logical database, listing the scopes that
|
|
3
|
-
* exist.
|
|
4
|
-
*
|
|
5
|
-
* This is the only structure that spans scopes, so it is deliberately the
|
|
6
|
-
* smallest thing that answers the questions the package must answer without
|
|
7
|
-
* opening every scope file. It holds a digest, timestamps, and two posture
|
|
8
|
-
* flags — **no raw scope components** (FR-014), so a device with full disk
|
|
9
|
-
* access cannot mine it for identities or tenant names even though the
|
|
10
|
-
* catalog itself is never encrypted.
|
|
11
|
-
*
|
|
12
|
-
* The `deleting` flag is the load-bearing part. It is written before a scope
|
|
13
|
-
* deletion begins and cleared last, so a crash at any point leaves the
|
|
14
|
-
* interruption visible on the next open. Clearing it first would make an
|
|
15
|
-
* interrupted deletion look complete — the exact failure FR-063 exists to
|
|
16
|
-
* prevent.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { ScopeDeletionState } from '../contract/scope';
|
|
20
|
-
import type { Connection } from './connection';
|
|
21
|
-
import { num, text } from './values';
|
|
22
|
-
|
|
23
|
-
export type CatalogEntry = {
|
|
24
|
-
scopeKey: string;
|
|
25
|
-
createdAt: number;
|
|
26
|
-
deletionState: ScopeDeletionState;
|
|
27
|
-
backupPosture: 'excluded' | 'included';
|
|
28
|
-
encrypted: boolean;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const CREATE_SCOPES = `CREATE TABLE IF NOT EXISTS scopes (
|
|
32
|
-
scope_key TEXT PRIMARY KEY,
|
|
33
|
-
created_at INTEGER NOT NULL,
|
|
34
|
-
deletion_state TEXT NOT NULL,
|
|
35
|
-
backup_posture TEXT NOT NULL,
|
|
36
|
-
encrypted INTEGER NOT NULL
|
|
37
|
-
)`;
|
|
38
|
-
|
|
39
|
-
export async function ensureCatalog(connection: Connection): Promise<void> {
|
|
40
|
-
await connection.execute(CREATE_SCOPES);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function readEntry(
|
|
44
|
-
connection: Connection,
|
|
45
|
-
scopeKey: string,
|
|
46
|
-
): Promise<CatalogEntry | null> {
|
|
47
|
-
const result = await connection.execute(
|
|
48
|
-
'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes WHERE scope_key = ?',
|
|
49
|
-
[scopeKey],
|
|
50
|
-
);
|
|
51
|
-
const row = result.rows[0];
|
|
52
|
-
if (!row) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
scopeKey: text(row.scope_key),
|
|
57
|
-
createdAt: num(row.created_at),
|
|
58
|
-
deletionState: text(row.deletion_state) as ScopeDeletionState,
|
|
59
|
-
backupPosture: text(row.backup_posture) as 'excluded' | 'included',
|
|
60
|
-
encrypted: num(row.encrypted) === 1,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function listEntries(connection: Connection): Promise<CatalogEntry[]> {
|
|
65
|
-
const result = await connection.execute(
|
|
66
|
-
'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes',
|
|
67
|
-
);
|
|
68
|
-
return result.rows.map(row => ({
|
|
69
|
-
scopeKey: text(row.scope_key),
|
|
70
|
-
createdAt: num(row.created_at),
|
|
71
|
-
deletionState: text(row.deletion_state) as ScopeDeletionState,
|
|
72
|
-
backupPosture: text(row.backup_posture) as 'excluded' | 'included',
|
|
73
|
-
encrypted: num(row.encrypted) === 1,
|
|
74
|
-
}));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function registerScope(
|
|
78
|
-
connection
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
entry.
|
|
90
|
-
entry.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
export async function clearScope(connection: Connection, scopeKey: string): Promise<void> {
|
|
107
|
-
await connection.execute('DELETE FROM scopes WHERE scope_key = ?', [scopeKey]);
|
|
108
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The catalog: one registry per logical database, listing the scopes that
|
|
3
|
+
* exist.
|
|
4
|
+
*
|
|
5
|
+
* This is the only structure that spans scopes, so it is deliberately the
|
|
6
|
+
* smallest thing that answers the questions the package must answer without
|
|
7
|
+
* opening every scope file. It holds a digest, timestamps, and two posture
|
|
8
|
+
* flags — **no raw scope components** (FR-014), so a device with full disk
|
|
9
|
+
* access cannot mine it for identities or tenant names even though the
|
|
10
|
+
* catalog itself is never encrypted.
|
|
11
|
+
*
|
|
12
|
+
* The `deleting` flag is the load-bearing part. It is written before a scope
|
|
13
|
+
* deletion begins and cleared last, so a crash at any point leaves the
|
|
14
|
+
* interruption visible on the next open. Clearing it first would make an
|
|
15
|
+
* interrupted deletion look complete — the exact failure FR-063 exists to
|
|
16
|
+
* prevent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ScopeDeletionState } from '../contract/scope';
|
|
20
|
+
import type { Connection } from './connection';
|
|
21
|
+
import { num, text } from './values';
|
|
22
|
+
|
|
23
|
+
export type CatalogEntry = {
|
|
24
|
+
scopeKey: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
deletionState: ScopeDeletionState;
|
|
27
|
+
backupPosture: 'excluded' | 'included';
|
|
28
|
+
encrypted: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CREATE_SCOPES = `CREATE TABLE IF NOT EXISTS scopes (
|
|
32
|
+
scope_key TEXT PRIMARY KEY,
|
|
33
|
+
created_at INTEGER NOT NULL,
|
|
34
|
+
deletion_state TEXT NOT NULL,
|
|
35
|
+
backup_posture TEXT NOT NULL,
|
|
36
|
+
encrypted INTEGER NOT NULL
|
|
37
|
+
)`;
|
|
38
|
+
|
|
39
|
+
export async function ensureCatalog(connection: Connection): Promise<void> {
|
|
40
|
+
await connection.execute(CREATE_SCOPES);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readEntry(
|
|
44
|
+
connection: Connection,
|
|
45
|
+
scopeKey: string,
|
|
46
|
+
): Promise<CatalogEntry | null> {
|
|
47
|
+
const result = await connection.execute(
|
|
48
|
+
'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes WHERE scope_key = ?',
|
|
49
|
+
[scopeKey],
|
|
50
|
+
);
|
|
51
|
+
const row = result.rows[0];
|
|
52
|
+
if (!row) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
scopeKey: text(row.scope_key),
|
|
57
|
+
createdAt: num(row.created_at),
|
|
58
|
+
deletionState: text(row.deletion_state) as ScopeDeletionState,
|
|
59
|
+
backupPosture: text(row.backup_posture) as 'excluded' | 'included',
|
|
60
|
+
encrypted: num(row.encrypted) === 1,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function listEntries(connection: Connection): Promise<CatalogEntry[]> {
|
|
65
|
+
const result = await connection.execute(
|
|
66
|
+
'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes',
|
|
67
|
+
);
|
|
68
|
+
return result.rows.map(row => ({
|
|
69
|
+
scopeKey: text(row.scope_key),
|
|
70
|
+
createdAt: num(row.created_at),
|
|
71
|
+
deletionState: text(row.deletion_state) as ScopeDeletionState,
|
|
72
|
+
backupPosture: text(row.backup_posture) as 'excluded' | 'included',
|
|
73
|
+
encrypted: num(row.encrypted) === 1,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function registerScope(connection: Connection, entry: CatalogEntry): Promise<void> {
|
|
78
|
+
await connection.execute(
|
|
79
|
+
`INSERT INTO scopes (scope_key, created_at, deletion_state, backup_posture, encrypted)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?)
|
|
81
|
+
ON CONFLICT(scope_key) DO UPDATE SET
|
|
82
|
+
deletion_state = excluded.deletion_state,
|
|
83
|
+
backup_posture = excluded.backup_posture,
|
|
84
|
+
encrypted = excluded.encrypted`,
|
|
85
|
+
[
|
|
86
|
+
entry.scopeKey,
|
|
87
|
+
entry.createdAt,
|
|
88
|
+
entry.deletionState,
|
|
89
|
+
entry.backupPosture,
|
|
90
|
+
entry.encrypted ? 1 : 0,
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function markDeleting(connection: Connection, scopeKey: string): Promise<void> {
|
|
96
|
+
await connection.execute('UPDATE scopes SET deletion_state = ? WHERE scope_key = ?', [
|
|
97
|
+
'deleting',
|
|
98
|
+
scopeKey,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Cleared last, on purpose. See the note at the top of this file. */
|
|
103
|
+
export async function clearScope(connection: Connection, scopeKey: string): Promise<void> {
|
|
104
|
+
await connection.execute('DELETE FROM scopes WHERE scope_key = ?', [scopeKey]);
|
|
105
|
+
}
|
package/src/sqlite/connection.ts
CHANGED
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One connection per database file.
|
|
3
|
-
*
|
|
4
|
-
* Two things about this file carry design weight:
|
|
5
|
-
*
|
|
6
|
-
* **Scope isolation is the file boundary.** A connection is opened against
|
|
7
|
-
* one scope's `data.db`, whose directory is named by the scope digest and
|
|
8
|
-
* never by the components (FR-014). No SQL this package could generate — and
|
|
9
|
-
* none a future bug could generate — reaches another scope's rows, because
|
|
10
|
-
* they are in a file this connection never opened (research.md R2).
|
|
11
|
-
*
|
|
12
|
-
* **A transaction owns the connection while it runs, and only its own
|
|
13
|
-
* executor reaches it.** Every operation queues on the connection, and a
|
|
14
|
-
* transaction holds the queue until it settles. Work issued through the
|
|
15
|
-
* connection meanwhile — a background sync, a second screen — waits and then
|
|
16
|
-
* runs on its own, rather than joining the transaction and vanishing with its
|
|
17
|
-
* rollback. Until 0.2.0 `execute` routed into whatever transaction was in
|
|
18
|
-
* flight, which is how an unrelated write was lost. Work meant to be part of
|
|
19
|
-
* the transaction goes through the executor the transaction hands out
|
|
20
|
-
* (`tx.collection`, `tx.attachments`).
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
24
|
-
import { raise } from '../diagnostics/errors';
|
|
25
|
-
import { raiseNative } from '../diagnostics/map-native';
|
|
26
|
-
import { fileSystem } from '../platform/fs';
|
|
27
|
-
import { createTransactionQueue } from './transaction-queue';
|
|
28
|
-
import {
|
|
29
|
-
engine,
|
|
30
|
-
type ExecuteResult,
|
|
31
|
-
type NativeDatabase,
|
|
32
|
-
type NativeTransaction,
|
|
33
|
-
type SqlValue,
|
|
34
|
-
} from './native';
|
|
35
|
-
|
|
36
|
-
export type Executor = {
|
|
37
|
-
execute(sql: string, params?: SqlValue[]): Promise<ExecuteResult>;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The executor a transaction body runs against, plus the one control it
|
|
42
|
-
* needs. `rollback` is handed to the body rather than returned from
|
|
43
|
-
* `transaction`, so an explicit abort cannot outlive the transaction it
|
|
44
|
-
* belongs to.
|
|
45
|
-
*/
|
|
46
|
-
export type TransactionExecutor = Executor & {
|
|
47
|
-
rollback(): Promise<void>;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type Connection = Executor & {
|
|
51
|
-
readonly path: string;
|
|
52
|
-
readonly encrypted: boolean;
|
|
53
|
-
transaction<R>(fn: (tx: TransactionExecutor) => Promise<R>): Promise<R>;
|
|
54
|
-
executeBatch(commands: [string, SqlValue[]?][]): Promise<void>;
|
|
55
|
-
/** Full-database check, for the diagnostics surface. Never run at open. */
|
|
56
|
-
integrityCheck(): Promise<boolean>;
|
|
57
|
-
close(): Promise<void>;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export type OpenConnectionOptions = {
|
|
61
|
-
/** Absolute path to the database file, already inside the managed area. */
|
|
62
|
-
path: string;
|
|
63
|
-
encryptionKey?: string;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
function splitPath(path: string): { location: string; name: string } {
|
|
67
|
-
const index = path.lastIndexOf('/');
|
|
68
|
-
return index === -1
|
|
69
|
-
? { location: '', name: path }
|
|
70
|
-
: { location: path.slice(0, index), name: path.slice(index + 1) };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function openConnection(options: OpenConnectionOptions): Promise<Connection> {
|
|
74
|
-
const { location, name } = splitPath(options.path);
|
|
75
|
-
const encrypted = options.encryptionKey !== undefined;
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
await fileSystem().mkdir(location);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
raiseNative(error, { operation: 'open' });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let database: NativeDatabase;
|
|
84
|
-
try {
|
|
85
|
-
database = engine().open({
|
|
86
|
-
name,
|
|
87
|
-
location,
|
|
88
|
-
// Passing the key only when encryption is on keeps an unencrypted
|
|
89
|
-
// database from being opened as an encrypted one with an empty key,
|
|
90
|
-
// which SQLCipher accepts and which would produce a file neither
|
|
91
|
-
// posture can reopen.
|
|
92
|
-
...(encrypted ? { encryptionKey: options.encryptionKey } : {}),
|
|
93
|
-
});
|
|
94
|
-
} catch (error) {
|
|
95
|
-
raiseNative(error, { operation: 'open', encrypted });
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let closed = false;
|
|
99
|
-
let active: NativeTransaction | null = null;
|
|
100
|
-
const queue = createTransactionQueue();
|
|
101
|
-
|
|
102
|
-
const assertOpen = (): void => {
|
|
103
|
-
if (closed) {
|
|
104
|
-
raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'connection is closed' });
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const classify = (sql: string) =>
|
|
109
|
-
/^\s*select|^\s*pragma|^\s*explain/i.test(sql) ? 'read' : 'write';
|
|
110
|
-
|
|
111
|
-
const connection: Connection = {
|
|
112
|
-
path: options.path,
|
|
113
|
-
encrypted,
|
|
114
|
-
|
|
115
|
-
execute(sql, params = []) {
|
|
116
|
-
assertOpen();
|
|
117
|
-
// Never into an in-flight transaction: queued behind it instead. See
|
|
118
|
-
// the note at the top of this file.
|
|
119
|
-
return queue.run(async () => {
|
|
120
|
-
try {
|
|
121
|
-
return await database.execute(sql, params);
|
|
122
|
-
} catch (error) {
|
|
123
|
-
raiseNative(error, { operation: classify(sql), encrypted });
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
async transaction(fn) {
|
|
129
|
-
assertOpen();
|
|
130
|
-
if (active) {
|
|
131
|
-
// Nested transactions would need savepoints and would let a
|
|
132
|
-
// partial rollback look like a full one. Refused rather than
|
|
133
|
-
// queued: a body that opened a second transaction would otherwise
|
|
134
|
-
// wait on itself forever.
|
|
135
|
-
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
136
|
-
message: 'a transaction is already in flight on this connection',
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return queue.runTransaction(async () => {
|
|
140
|
-
try {
|
|
141
|
-
return await database.transaction(async tx => {
|
|
142
|
-
active = tx;
|
|
143
|
-
let settled = false;
|
|
144
|
-
try {
|
|
145
|
-
return await fn({
|
|
146
|
-
execute: (sql, params = []) => {
|
|
147
|
-
if (settled) {
|
|
148
|
-
// After an explicit rollback the body is still running
|
|
149
|
-
// inside the engine's callback. Its remaining statements
|
|
150
|
-
// are refused, not run in autocommit (LDB-V1).
|
|
151
|
-
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
152
|
-
message: 'transaction already rolled back',
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
return tx.execute(sql, params);
|
|
156
|
-
},
|
|
157
|
-
rollback: async () => {
|
|
158
|
-
await tx.rollback();
|
|
159
|
-
settled = true;
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
} finally {
|
|
163
|
-
active = null;
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
} catch (error) {
|
|
167
|
-
raiseNative(error, { operation: 'transaction', encrypted });
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
executeBatch(commands) {
|
|
173
|
-
assertOpen();
|
|
174
|
-
return queue.run(async () => {
|
|
175
|
-
try {
|
|
176
|
-
await database.executeBatch(commands);
|
|
177
|
-
} catch (error) {
|
|
178
|
-
raiseNative(error, { operation: 'write', encrypted });
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
},
|
|
182
|
-
|
|
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
|
-
async close() {
|
|
191
|
-
if (closed) {
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
closed = true;
|
|
195
|
-
try {
|
|
196
|
-
await database.close();
|
|
197
|
-
} catch {
|
|
198
|
-
// A close that fails leaves nothing for a caller to do, and
|
|
199
|
-
// reporting it would turn an orderly sign-out into an error.
|
|
200
|
-
}
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Write-ahead logging so a reader is not blocked by the writer — FR-045's
|
|
205
|
-
// "reads proceed while a write is in flight" is this pragma plus op-sqlite's
|
|
206
|
-
// single-writer serialization, not a lock this package maintains.
|
|
207
|
-
await connection.execute('PRAGMA journal_mode = WAL');
|
|
208
|
-
await connection.execute('PRAGMA foreign_keys = ON');
|
|
209
|
-
// Durability over speed: a half-written record after a crash is exactly
|
|
210
|
-
// what FR-039 promises never happens.
|
|
211
|
-
await connection.execute('PRAGMA synchronous = FULL');
|
|
212
|
-
|
|
213
|
-
return connection;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Confirm a freshly opened database is readable.
|
|
218
|
-
*
|
|
219
|
-
* Deliberately not `PRAGMA integrity_check`: that walks the whole file and
|
|
220
|
-
* would put open time far past the 150-millisecond budget on a large scope.
|
|
221
|
-
* One trivial read is enough to surface the failures that matter at open —
|
|
222
|
-
* a wrong key, a truncated file, a malformed header — because all three fail
|
|
223
|
-
* it, and the error mapping keeps them distinguishable (FR-076, FR-078).
|
|
224
|
-
*/
|
|
225
|
-
export async function assertReadable(connection: Connection): Promise<void> {
|
|
226
|
-
await connection.execute('SELECT count(*) AS n FROM sqlite_master');
|
|
227
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* One connection per database file.
|
|
3
|
+
*
|
|
4
|
+
* Two things about this file carry design weight:
|
|
5
|
+
*
|
|
6
|
+
* **Scope isolation is the file boundary.** A connection is opened against
|
|
7
|
+
* one scope's `data.db`, whose directory is named by the scope digest and
|
|
8
|
+
* never by the components (FR-014). No SQL this package could generate — and
|
|
9
|
+
* none a future bug could generate — reaches another scope's rows, because
|
|
10
|
+
* they are in a file this connection never opened (research.md R2).
|
|
11
|
+
*
|
|
12
|
+
* **A transaction owns the connection while it runs, and only its own
|
|
13
|
+
* executor reaches it.** Every operation queues on the connection, and a
|
|
14
|
+
* transaction holds the queue until it settles. Work issued through the
|
|
15
|
+
* connection meanwhile — a background sync, a second screen — waits and then
|
|
16
|
+
* runs on its own, rather than joining the transaction and vanishing with its
|
|
17
|
+
* rollback. Until 0.2.0 `execute` routed into whatever transaction was in
|
|
18
|
+
* flight, which is how an unrelated write was lost. Work meant to be part of
|
|
19
|
+
* the transaction goes through the executor the transaction hands out
|
|
20
|
+
* (`tx.collection`, `tx.attachments`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
24
|
+
import { raise } from '../diagnostics/errors';
|
|
25
|
+
import { raiseNative } from '../diagnostics/map-native';
|
|
26
|
+
import { fileSystem } from '../platform/fs';
|
|
27
|
+
import { createTransactionQueue } from './transaction-queue';
|
|
28
|
+
import {
|
|
29
|
+
engine,
|
|
30
|
+
type ExecuteResult,
|
|
31
|
+
type NativeDatabase,
|
|
32
|
+
type NativeTransaction,
|
|
33
|
+
type SqlValue,
|
|
34
|
+
} from './native';
|
|
35
|
+
|
|
36
|
+
export type Executor = {
|
|
37
|
+
execute(sql: string, params?: SqlValue[]): Promise<ExecuteResult>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The executor a transaction body runs against, plus the one control it
|
|
42
|
+
* needs. `rollback` is handed to the body rather than returned from
|
|
43
|
+
* `transaction`, so an explicit abort cannot outlive the transaction it
|
|
44
|
+
* belongs to.
|
|
45
|
+
*/
|
|
46
|
+
export type TransactionExecutor = Executor & {
|
|
47
|
+
rollback(): Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type Connection = Executor & {
|
|
51
|
+
readonly path: string;
|
|
52
|
+
readonly encrypted: boolean;
|
|
53
|
+
transaction<R>(fn: (tx: TransactionExecutor) => Promise<R>): Promise<R>;
|
|
54
|
+
executeBatch(commands: [string, SqlValue[]?][]): Promise<void>;
|
|
55
|
+
/** Full-database check, for the diagnostics surface. Never run at open. */
|
|
56
|
+
integrityCheck(): Promise<boolean>;
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type OpenConnectionOptions = {
|
|
61
|
+
/** Absolute path to the database file, already inside the managed area. */
|
|
62
|
+
path: string;
|
|
63
|
+
encryptionKey?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function splitPath(path: string): { location: string; name: string } {
|
|
67
|
+
const index = path.lastIndexOf('/');
|
|
68
|
+
return index === -1
|
|
69
|
+
? { location: '', name: path }
|
|
70
|
+
: { location: path.slice(0, index), name: path.slice(index + 1) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function openConnection(options: OpenConnectionOptions): Promise<Connection> {
|
|
74
|
+
const { location, name } = splitPath(options.path);
|
|
75
|
+
const encrypted = options.encryptionKey !== undefined;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await fileSystem().mkdir(location);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
raiseNative(error, { operation: 'open' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let database: NativeDatabase;
|
|
84
|
+
try {
|
|
85
|
+
database = engine().open({
|
|
86
|
+
name,
|
|
87
|
+
location,
|
|
88
|
+
// Passing the key only when encryption is on keeps an unencrypted
|
|
89
|
+
// database from being opened as an encrypted one with an empty key,
|
|
90
|
+
// which SQLCipher accepts and which would produce a file neither
|
|
91
|
+
// posture can reopen.
|
|
92
|
+
...(encrypted ? { encryptionKey: options.encryptionKey } : {}),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
raiseNative(error, { operation: 'open', encrypted });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let closed = false;
|
|
99
|
+
let active: NativeTransaction | null = null;
|
|
100
|
+
const queue = createTransactionQueue();
|
|
101
|
+
|
|
102
|
+
const assertOpen = (): void => {
|
|
103
|
+
if (closed) {
|
|
104
|
+
raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'connection is closed' });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const classify = (sql: string) =>
|
|
109
|
+
/^\s*select|^\s*pragma|^\s*explain/i.test(sql) ? 'read' : 'write';
|
|
110
|
+
|
|
111
|
+
const connection: Connection = {
|
|
112
|
+
path: options.path,
|
|
113
|
+
encrypted,
|
|
114
|
+
|
|
115
|
+
execute(sql, params = []) {
|
|
116
|
+
assertOpen();
|
|
117
|
+
// Never into an in-flight transaction: queued behind it instead. See
|
|
118
|
+
// the note at the top of this file.
|
|
119
|
+
return queue.run(async () => {
|
|
120
|
+
try {
|
|
121
|
+
return await database.execute(sql, params);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
raiseNative(error, { operation: classify(sql), encrypted });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async transaction(fn) {
|
|
129
|
+
assertOpen();
|
|
130
|
+
if (active) {
|
|
131
|
+
// Nested transactions would need savepoints and would let a
|
|
132
|
+
// partial rollback look like a full one. Refused rather than
|
|
133
|
+
// queued: a body that opened a second transaction would otherwise
|
|
134
|
+
// wait on itself forever.
|
|
135
|
+
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
136
|
+
message: 'a transaction is already in flight on this connection',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return queue.runTransaction(async () => {
|
|
140
|
+
try {
|
|
141
|
+
return await database.transaction(async tx => {
|
|
142
|
+
active = tx;
|
|
143
|
+
let settled = false;
|
|
144
|
+
try {
|
|
145
|
+
return await fn({
|
|
146
|
+
execute: (sql, params = []) => {
|
|
147
|
+
if (settled) {
|
|
148
|
+
// After an explicit rollback the body is still running
|
|
149
|
+
// inside the engine's callback. Its remaining statements
|
|
150
|
+
// are refused, not run in autocommit (LDB-V1).
|
|
151
|
+
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
152
|
+
message: 'transaction already rolled back',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return tx.execute(sql, params);
|
|
156
|
+
},
|
|
157
|
+
rollback: async () => {
|
|
158
|
+
await tx.rollback();
|
|
159
|
+
settled = true;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
active = null;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
raiseNative(error, { operation: 'transaction', encrypted });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
executeBatch(commands) {
|
|
173
|
+
assertOpen();
|
|
174
|
+
return queue.run(async () => {
|
|
175
|
+
try {
|
|
176
|
+
await database.executeBatch(commands);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
raiseNative(error, { operation: 'write', encrypted });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
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
|
+
async close() {
|
|
191
|
+
if (closed) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
closed = true;
|
|
195
|
+
try {
|
|
196
|
+
await database.close();
|
|
197
|
+
} catch {
|
|
198
|
+
// A close that fails leaves nothing for a caller to do, and
|
|
199
|
+
// reporting it would turn an orderly sign-out into an error.
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Write-ahead logging so a reader is not blocked by the writer — FR-045's
|
|
205
|
+
// "reads proceed while a write is in flight" is this pragma plus op-sqlite's
|
|
206
|
+
// single-writer serialization, not a lock this package maintains.
|
|
207
|
+
await connection.execute('PRAGMA journal_mode = WAL');
|
|
208
|
+
await connection.execute('PRAGMA foreign_keys = ON');
|
|
209
|
+
// Durability over speed: a half-written record after a crash is exactly
|
|
210
|
+
// what FR-039 promises never happens.
|
|
211
|
+
await connection.execute('PRAGMA synchronous = FULL');
|
|
212
|
+
|
|
213
|
+
return connection;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Confirm a freshly opened database is readable.
|
|
218
|
+
*
|
|
219
|
+
* Deliberately not `PRAGMA integrity_check`: that walks the whole file and
|
|
220
|
+
* would put open time far past the 150-millisecond budget on a large scope.
|
|
221
|
+
* One trivial read is enough to surface the failures that matter at open —
|
|
222
|
+
* a wrong key, a truncated file, a malformed header — because all three fail
|
|
223
|
+
* it, and the error mapping keeps them distinguishable (FR-076, FR-078).
|
|
224
|
+
*/
|
|
225
|
+
export async function assertReadable(connection: Connection): Promise<void> {
|
|
226
|
+
await connection.execute('SELECT count(*) AS n FROM sqlite_master');
|
|
227
|
+
}
|