@chipmobilesdk/rn-local-db 0.1.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 +52 -0
- package/README.md +726 -0
- package/package.json +61 -0
- package/src/adapter/budgets.ts +67 -0
- package/src/adapter/collection.ts +298 -0
- package/src/adapter/deletion.ts +238 -0
- package/src/adapter/index.ts +68 -0
- package/src/adapter/lifecycle.ts +444 -0
- package/src/adapter/transaction.ts +65 -0
- package/src/artifacts/availability.ts +69 -0
- package/src/artifacts/cascade.ts +78 -0
- package/src/artifacts/paths.ts +29 -0
- package/src/artifacts/reconcile.ts +63 -0
- package/src/artifacts/store.ts +323 -0
- package/src/contract/adapter.ts +78 -0
- package/src/contract/attachment.ts +72 -0
- package/src/contract/backup.ts +28 -0
- package/src/contract/config.ts +54 -0
- package/src/contract/encryption.ts +34 -0
- package/src/contract/errors.ts +76 -0
- package/src/contract/migration.ts +40 -0
- package/src/contract/query.ts +81 -0
- package/src/contract/record.ts +42 -0
- package/src/contract/schema.ts +36 -0
- package/src/contract/scope.ts +36 -0
- package/src/contract/transaction.ts +46 -0
- package/src/crypto/chunked-cipher.ts +144 -0
- package/src/crypto/derive.ts +63 -0
- package/src/crypto/key-provider.ts +78 -0
- package/src/crypto/provider.ts +81 -0
- package/src/diagnostics/dev-log.ts +108 -0
- package/src/diagnostics/errors.ts +66 -0
- package/src/diagnostics/faults.ts +64 -0
- package/src/diagnostics/map-native.ts +106 -0
- package/src/diagnostics/redact.ts +141 -0
- package/src/index.ts +110 -0
- package/src/migration/executor.ts +136 -0
- package/src/migration/ledger.ts +97 -0
- package/src/migration/validate-chain.ts +65 -0
- package/src/platform/backup-android-verify.ts +88 -0
- package/src/platform/backup-ios.ts +48 -0
- package/src/platform/fs.ts +89 -0
- package/src/platform/location.ts +135 -0
- package/src/query/budgets.ts +57 -0
- package/src/query/compare.ts +91 -0
- package/src/query/cursor.ts +99 -0
- package/src/query/filter.ts +82 -0
- package/src/query/paginate.ts +51 -0
- package/src/query/sort.ts +56 -0
- package/src/query/validate.ts +171 -0
- package/src/schema/declare.ts +45 -0
- package/src/schema/validate-config.ts +158 -0
- package/src/scope/hash.ts +134 -0
- package/src/scope/normalize.ts +90 -0
- package/src/scope/validate.ts +83 -0
- package/src/sqlite/adapter.ts +26 -0
- package/src/sqlite/batch.ts +232 -0
- package/src/sqlite/catalog.ts +108 -0
- package/src/sqlite/connection.ts +205 -0
- package/src/sqlite/index-coverage.ts +53 -0
- package/src/sqlite/native.ts +83 -0
- package/src/sqlite/row-codec.ts +201 -0
- package/src/sqlite/sql-builder.ts +244 -0
- package/src/sqlite/transactions.ts +33 -0
- package/src/sqlite/values.ts +24 -0
- package/src/testing/conformance/attachments.ts +136 -0
- package/src/testing/conformance/encryption.ts +150 -0
- package/src/testing/conformance/index.ts +161 -0
- package/src/testing/conformance/lifecycle.ts +99 -0
- package/src/testing/conformance/migrations.ts +177 -0
- package/src/testing/conformance/queries.ts +167 -0
- package/src/testing/conformance/records.ts +169 -0
- package/src/testing/conformance/scopes.ts +88 -0
- package/src/testing/conformance/transactions.ts +149 -0
- package/src/testing/faults.ts +21 -0
- package/src/testing/index.ts +21 -0
- package/src/testing/memory-adapter/collection.ts +219 -0
- package/src/testing/memory-adapter/index.ts +388 -0
- package/src/testing/memory-adapter/migrations.ts +119 -0
- package/src/testing/memory-adapter/reset.ts +50 -0
- package/src/testing/memory-adapter/store.ts +134 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascading artifact removal.
|
|
3
|
+
*
|
|
4
|
+
* The package deletes the files, because the Q2 clarification made it the
|
|
5
|
+
* owner of them (FR-073c). That is the single riskiest thing this package
|
|
6
|
+
* does — POL-011(b)'s media-and-files row exists for it — so two rules hold
|
|
7
|
+
* without exception: every path goes through the boundary guard in
|
|
8
|
+
* `paths.ts`, and the outcome reports what was removed **and what could not
|
|
9
|
+
* be**, never a bare success (FR-071).
|
|
10
|
+
*
|
|
11
|
+
* A failed removal is reported rather than retried or swallowed. A file the
|
|
12
|
+
* package could not delete is still on disk holding the user's data, and an
|
|
13
|
+
* app that told them "deleted" would have said something untrue.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AttachmentOwner, DeletionOutcome } from '../contract/attachment';
|
|
17
|
+
import { removeIfPresent } from '../platform/fs';
|
|
18
|
+
import type { Executor } from '../sqlite/connection';
|
|
19
|
+
import { artifactPath } from './paths';
|
|
20
|
+
import {
|
|
21
|
+
deleteReference,
|
|
22
|
+
listReferences,
|
|
23
|
+
listReferencesFor,
|
|
24
|
+
type ArtifactContext,
|
|
25
|
+
} from './store';
|
|
26
|
+
|
|
27
|
+
async function removeAll(
|
|
28
|
+
context: ArtifactContext,
|
|
29
|
+
executor: Executor,
|
|
30
|
+
ids: string[],
|
|
31
|
+
): Promise<DeletionOutcome> {
|
|
32
|
+
const removedArtifacts: string[] = [];
|
|
33
|
+
const failedRemovals: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const id of ids) {
|
|
36
|
+
// A file that was already gone counts as removed: the end state the
|
|
37
|
+
// caller asked for is the end state they got.
|
|
38
|
+
await removeIfPresent(artifactPath(context.layout, context.scopeKey, id));
|
|
39
|
+
removedArtifacts.push(id);
|
|
40
|
+
if (!(await deleteReference(executor, id))) {
|
|
41
|
+
failedRemovals.push(id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
removedRecords: 0,
|
|
47
|
+
removedArtifacts,
|
|
48
|
+
orphanedReferences: [],
|
|
49
|
+
failedRemovals,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Everything belonging to one record, for the FR-070 cascade. */
|
|
54
|
+
export async function cascadeForOwner(
|
|
55
|
+
context: ArtifactContext,
|
|
56
|
+
executor: Executor,
|
|
57
|
+
owner: AttachmentOwner,
|
|
58
|
+
): Promise<DeletionOutcome> {
|
|
59
|
+
const references = await listReferencesFor(executor, owner);
|
|
60
|
+
return removeAll(
|
|
61
|
+
context,
|
|
62
|
+
executor,
|
|
63
|
+
references.map(reference => reference.id),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything in the scope, for scope deletion. */
|
|
68
|
+
export async function cascadeAll(
|
|
69
|
+
context: ArtifactContext,
|
|
70
|
+
executor: Executor,
|
|
71
|
+
): Promise<DeletionOutcome> {
|
|
72
|
+
const references = await listReferences(executor);
|
|
73
|
+
return removeAll(
|
|
74
|
+
context,
|
|
75
|
+
executor,
|
|
76
|
+
references.map(reference => reference.id),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every artifact path, resolved through one guard.
|
|
3
|
+
*
|
|
4
|
+
* POL-011(b) flags media-and-files as a risk precisely because this package
|
|
5
|
+
* deletes files from disk on cascade. The mitigation is structural rather
|
|
6
|
+
* than procedural: no code in `artifacts/` builds a path by string
|
|
7
|
+
* concatenation, and the one function that turns an attachment id into a path
|
|
8
|
+
* refuses anything escaping the managed root (FR-073, FR-073f).
|
|
9
|
+
*
|
|
10
|
+
* That is what makes SC-013a's filesystem tracing pass reliably rather than
|
|
11
|
+
* usually — there is no second path to audit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { assertWithinManagedArea, type StorageLayout } from '../platform/location';
|
|
15
|
+
|
|
16
|
+
export function artifactsDirectory(layout: StorageLayout, scopeKey: string): string {
|
|
17
|
+
return assertWithinManagedArea(layout, layout.scopeArtifactsPath(scopeKey));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function artifactPath(
|
|
21
|
+
layout: StorageLayout,
|
|
22
|
+
scopeKey: string,
|
|
23
|
+
attachmentId: string,
|
|
24
|
+
): string {
|
|
25
|
+
// `layout.artifactPath` already rejects an id that is not a safe single
|
|
26
|
+
// path segment; the guard then re-checks the assembled result, so a change
|
|
27
|
+
// to either one alone cannot open a hole.
|
|
28
|
+
return assertWithinManagedArea(layout, layout.artifactPath(scopeKey, attachmentId));
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciliation: the two ways the database and the disk can disagree.
|
|
3
|
+
*
|
|
4
|
+
* An **orphaned artifact** is a file with no reference — left by a rolled-back
|
|
5
|
+
* transaction, or by a crash between the write and the insert. A **dangling
|
|
6
|
+
* reference** is the reverse: a row pointing at a file the operating system
|
|
7
|
+
* reclaimed, or the user deleted through a file manager.
|
|
8
|
+
*
|
|
9
|
+
* App-triggered only (FR-073d). A background sweep would run during a user's
|
|
10
|
+
* session without their knowledge, delete files it believes are orphans, and
|
|
11
|
+
* have no way to distinguish "orphan" from "written by a transaction that has
|
|
12
|
+
* not committed yet". Handing the trigger to the app makes that an explicit
|
|
13
|
+
* decision at a moment the app knows is quiet.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ReconciliationReport } from '../contract/attachment';
|
|
17
|
+
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
18
|
+
import { byteOrder } from '../query/compare';
|
|
19
|
+
import type { Executor } from '../sqlite/connection';
|
|
20
|
+
import { artifactPath, artifactsDirectory } from './paths';
|
|
21
|
+
import { listReferences, type ArtifactContext } from './store';
|
|
22
|
+
|
|
23
|
+
export type ReconcileOptions = {
|
|
24
|
+
/** Off by default: deleting a user's file is never an implied yes. */
|
|
25
|
+
removeOrphans?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function filesOnDisk(context: ArtifactContext): Promise<string[]> {
|
|
29
|
+
try {
|
|
30
|
+
return await fileSystem().readdir(artifactsDirectory(context.layout, context.scopeKey));
|
|
31
|
+
} catch {
|
|
32
|
+
// No directory means no artifacts, which is a valid answer rather than
|
|
33
|
+
// a failure — a scope that never wrote one never created it.
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function reconcileArtifacts(
|
|
39
|
+
context: ArtifactContext,
|
|
40
|
+
executor: Executor,
|
|
41
|
+
options: ReconcileOptions = {},
|
|
42
|
+
): Promise<ReconciliationReport> {
|
|
43
|
+
const references = await listReferences(executor);
|
|
44
|
+
const referenced = new Set(references.map(reference => reference.id));
|
|
45
|
+
const present = new Set(await filesOnDisk(context));
|
|
46
|
+
|
|
47
|
+
const orphanedArtifacts = [...present].filter(id => !referenced.has(id)).sort(byteOrder);
|
|
48
|
+
const danglingReferences = references
|
|
49
|
+
.filter(reference => !present.has(reference.id))
|
|
50
|
+
.map(reference => reference.id)
|
|
51
|
+
.sort(byteOrder);
|
|
52
|
+
|
|
53
|
+
const removedArtifacts: string[] = [];
|
|
54
|
+
if (options.removeOrphans) {
|
|
55
|
+
for (const id of orphanedArtifacts) {
|
|
56
|
+
if (await removeIfPresent(artifactPath(context.layout, context.scopeKey, id))) {
|
|
57
|
+
removedArtifacts.push(id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { orphanedArtifacts, danglingReferences, removedArtifacts };
|
|
63
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifacts: the file on disk and the reference to it.
|
|
3
|
+
*
|
|
4
|
+
* The reference lives in the scope's own database, so a reference created
|
|
5
|
+
* inside a transaction rolls back with it (FR-072) — no bookkeeping required,
|
|
6
|
+
* because the connection routes through the transaction in flight.
|
|
7
|
+
*
|
|
8
|
+
* The file does not roll back. A filesystem write is not part of a SQL
|
|
9
|
+
* transaction and cannot be made part of one, so a rolled-back transaction
|
|
10
|
+
* leaves an artifact on disk with nothing pointing at it. That is not a bug
|
|
11
|
+
* to be engineered away; it is why reconciliation exists (FR-073d), and why
|
|
12
|
+
* the write order is deliberate: **the file completes first, then the
|
|
13
|
+
* reference**. The reverse order would produce a reference to a file that
|
|
14
|
+
* does not exist, which is the failure a consumer cannot recover from — it
|
|
15
|
+
* looks like data the app has and does not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AttachmentInput,
|
|
20
|
+
AttachmentOwner,
|
|
21
|
+
AttachmentReference,
|
|
22
|
+
ByteRange,
|
|
23
|
+
DeletionOutcome,
|
|
24
|
+
} from '../contract/attachment';
|
|
25
|
+
import { Budgets } from '../contract/config';
|
|
26
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
27
|
+
import { isStorageError, raise } from '../diagnostics/errors';
|
|
28
|
+
import { throwIfFaulted } from '../diagnostics/faults';
|
|
29
|
+
import { raiseNative } from '../diagnostics/map-native';
|
|
30
|
+
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
31
|
+
import type { StorageLayout } from '../platform/location';
|
|
32
|
+
import { assertArtifactWithinBudget } from '../query/budgets';
|
|
33
|
+
import {
|
|
34
|
+
HEADER_BYTES,
|
|
35
|
+
TAG_BYTES,
|
|
36
|
+
chunkOffset,
|
|
37
|
+
chunkPlaintextLength,
|
|
38
|
+
decodeHeader,
|
|
39
|
+
decryptChunk,
|
|
40
|
+
encodeHeader,
|
|
41
|
+
encryptChunk,
|
|
42
|
+
headerFor,
|
|
43
|
+
} from '../crypto/chunked-cipher';
|
|
44
|
+
import type { Executor } from '../sqlite/connection';
|
|
45
|
+
import { maybeNum, num, text } from '../sqlite/values';
|
|
46
|
+
import { artifactPath, artifactsDirectory } from './paths';
|
|
47
|
+
|
|
48
|
+
const CREATE_ATTACHMENTS = `CREATE TABLE IF NOT EXISTS _attachments (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
owner_collection TEXT,
|
|
51
|
+
owner_id TEXT,
|
|
52
|
+
content_type TEXT NOT NULL,
|
|
53
|
+
size INTEGER NOT NULL,
|
|
54
|
+
chunk_size INTEGER,
|
|
55
|
+
chunk_count INTEGER,
|
|
56
|
+
created_at INTEGER NOT NULL
|
|
57
|
+
)`;
|
|
58
|
+
|
|
59
|
+
const CREATE_OWNER_INDEX =
|
|
60
|
+
'CREATE INDEX IF NOT EXISTS _attachments_owner ON _attachments (owner_collection, owner_id)';
|
|
61
|
+
|
|
62
|
+
export type ArtifactContext = {
|
|
63
|
+
executor(): Executor;
|
|
64
|
+
layout: StorageLayout;
|
|
65
|
+
scopeKey: string;
|
|
66
|
+
/** Null when the database is unencrypted. */
|
|
67
|
+
artifactKey(attachmentId: string): Uint8Array | null;
|
|
68
|
+
now(): number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function ensureAttachmentTable(executor: Executor): Promise<void> {
|
|
72
|
+
await executor.execute(CREATE_ATTACHMENTS);
|
|
73
|
+
await executor.execute(CREATE_OWNER_INDEX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toReference(row: Record<string, unknown>): AttachmentReference {
|
|
77
|
+
const reference: AttachmentReference = {
|
|
78
|
+
id: text(row.id),
|
|
79
|
+
contentType: text(row.content_type),
|
|
80
|
+
size: num(row.size),
|
|
81
|
+
createdAt: num(row.created_at),
|
|
82
|
+
};
|
|
83
|
+
if (row.owner_collection != null && row.owner_id != null) {
|
|
84
|
+
reference.owner = { collection: text(row.owner_collection), id: text(row.owner_id) };
|
|
85
|
+
}
|
|
86
|
+
const chunkSize = maybeNum(row.chunk_size);
|
|
87
|
+
if (chunkSize !== null) {
|
|
88
|
+
reference.chunkSize = chunkSize;
|
|
89
|
+
reference.chunkCount = num(row.chunk_count);
|
|
90
|
+
}
|
|
91
|
+
return reference;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function readReference(
|
|
95
|
+
executor: Executor,
|
|
96
|
+
id: string,
|
|
97
|
+
): Promise<AttachmentReference | null> {
|
|
98
|
+
const result = await executor.execute('SELECT * FROM _attachments WHERE id = ?', [id]);
|
|
99
|
+
const row = result.rows[0];
|
|
100
|
+
return row ? toReference(row) : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function listReferences(executor: Executor): Promise<AttachmentReference[]> {
|
|
104
|
+
const result = await executor.execute('SELECT * FROM _attachments ORDER BY id ASC');
|
|
105
|
+
return result.rows.map(toReference);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function listReferencesFor(
|
|
109
|
+
executor: Executor,
|
|
110
|
+
owner: AttachmentOwner,
|
|
111
|
+
): Promise<AttachmentReference[]> {
|
|
112
|
+
const result = await executor.execute(
|
|
113
|
+
'SELECT * FROM _attachments WHERE owner_collection = ? AND owner_id = ? ORDER BY id ASC',
|
|
114
|
+
[owner.collection, owner.id],
|
|
115
|
+
);
|
|
116
|
+
return result.rows.map(toReference);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function insertReference(
|
|
120
|
+
executor: Executor,
|
|
121
|
+
reference: AttachmentReference,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
await executor.execute(
|
|
124
|
+
`INSERT INTO _attachments
|
|
125
|
+
(id, owner_collection, owner_id, content_type, size, chunk_size, chunk_count, created_at)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
127
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
128
|
+
owner_collection = excluded.owner_collection,
|
|
129
|
+
owner_id = excluded.owner_id,
|
|
130
|
+
content_type = excluded.content_type,
|
|
131
|
+
size = excluded.size,
|
|
132
|
+
chunk_size = excluded.chunk_size,
|
|
133
|
+
chunk_count = excluded.chunk_count,
|
|
134
|
+
created_at = excluded.created_at`,
|
|
135
|
+
[
|
|
136
|
+
reference.id,
|
|
137
|
+
reference.owner?.collection ?? null,
|
|
138
|
+
reference.owner?.id ?? null,
|
|
139
|
+
reference.contentType,
|
|
140
|
+
reference.size,
|
|
141
|
+
reference.chunkSize ?? null,
|
|
142
|
+
reference.chunkCount ?? null,
|
|
143
|
+
reference.createdAt,
|
|
144
|
+
],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function deleteReference(executor: Executor, id: string): Promise<boolean> {
|
|
149
|
+
const result = await executor.execute('DELETE FROM _attachments WHERE id = ?', [id]);
|
|
150
|
+
return result.rowsAffected > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function writePlaintext(path: string, content: string): Promise<void> {
|
|
154
|
+
await fileSystem().writeFile(path, content, 'base64');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Encrypted write, one chunk in flight.
|
|
159
|
+
*
|
|
160
|
+
* The header goes down first because its contents — chunk size, chunk count,
|
|
161
|
+
* plaintext length — are all known from the input length before any
|
|
162
|
+
* encryption happens. Only the tags depend on the ciphertext, and they follow
|
|
163
|
+
* their own chunk, so the whole write is append-only and peak memory is one
|
|
164
|
+
* chunk regardless of artifact size (FR-107a).
|
|
165
|
+
*/
|
|
166
|
+
async function writeEncrypted(
|
|
167
|
+
path: string,
|
|
168
|
+
key: Uint8Array,
|
|
169
|
+
plaintext: Buffer,
|
|
170
|
+
): Promise<{ chunkSize: number; chunkCount: number }> {
|
|
171
|
+
const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
|
|
172
|
+
await fileSystem().writeFile(path, encodeHeader(header).toString('base64'), 'base64');
|
|
173
|
+
|
|
174
|
+
for (let index = 0; index < header.chunkCount; index += 1) {
|
|
175
|
+
const start = index * header.chunkSize;
|
|
176
|
+
const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
|
|
177
|
+
await fileSystem().appendFile(
|
|
178
|
+
path,
|
|
179
|
+
encryptChunk(key, index, chunk).toString('base64'),
|
|
180
|
+
'base64',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { chunkSize: header.chunkSize, chunkCount: header.chunkCount };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function writeArtifact(
|
|
188
|
+
context: ArtifactContext,
|
|
189
|
+
input: AttachmentInput,
|
|
190
|
+
): Promise<AttachmentReference> {
|
|
191
|
+
throwIfFaulted('attachmentWrite');
|
|
192
|
+
const plaintext = Buffer.from(input.content, 'base64');
|
|
193
|
+
assertArtifactWithinBudget(input.id, plaintext.length);
|
|
194
|
+
|
|
195
|
+
const path = artifactPath(context.layout, context.scopeKey, input.id);
|
|
196
|
+
const key = context.artifactKey(input.id);
|
|
197
|
+
|
|
198
|
+
let chunking: { chunkSize: number; chunkCount: number } | undefined;
|
|
199
|
+
try {
|
|
200
|
+
await fileSystem().mkdir(artifactsDirectory(context.layout, context.scopeKey));
|
|
201
|
+
if (key) {
|
|
202
|
+
chunking = await writeEncrypted(path, key, plaintext);
|
|
203
|
+
} else {
|
|
204
|
+
await writePlaintext(path, input.content);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// A partial file with no reference is an orphan reconciliation can
|
|
208
|
+
// find; a reference to a partial file is data the app believes it has.
|
|
209
|
+
await removeIfPresent(path);
|
|
210
|
+
raiseNative(error, { operation: 'attachment' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const reference: AttachmentReference = {
|
|
214
|
+
id: input.id,
|
|
215
|
+
contentType: input.contentType,
|
|
216
|
+
size: plaintext.length,
|
|
217
|
+
createdAt: context.now(),
|
|
218
|
+
};
|
|
219
|
+
if (input.owner) {
|
|
220
|
+
reference.owner = input.owner;
|
|
221
|
+
}
|
|
222
|
+
if (chunking) {
|
|
223
|
+
reference.chunkSize = chunking.chunkSize;
|
|
224
|
+
reference.chunkCount = chunking.chunkCount;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Only now. See the note at the top of this file.
|
|
228
|
+
await insertReference(context.executor(), reference);
|
|
229
|
+
return reference;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readPlaintext(path: string, range?: ByteRange): Promise<string> {
|
|
233
|
+
if (!range) {
|
|
234
|
+
return fileSystem().readFile(path, 'base64');
|
|
235
|
+
}
|
|
236
|
+
return fileSystem().read(path, range.length, range.offset, 'base64');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readEncrypted(
|
|
240
|
+
path: string,
|
|
241
|
+
key: Uint8Array,
|
|
242
|
+
range?: ByteRange,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
const headerBytes = Buffer.from(
|
|
245
|
+
await fileSystem().read(path, HEADER_BYTES, 0, 'base64'),
|
|
246
|
+
'base64',
|
|
247
|
+
);
|
|
248
|
+
const header = decodeHeader(headerBytes);
|
|
249
|
+
|
|
250
|
+
const from = range?.offset ?? 0;
|
|
251
|
+
const to = range ? Math.min(header.plaintextSize, from + range.length) : header.plaintextSize;
|
|
252
|
+
if (to <= from) {
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const first = Math.floor(from / header.chunkSize);
|
|
257
|
+
const last = Math.floor((to - 1) / header.chunkSize);
|
|
258
|
+
const pieces: Buffer[] = [];
|
|
259
|
+
|
|
260
|
+
for (let index = first; index <= last; index += 1) {
|
|
261
|
+
const stored = Buffer.from(
|
|
262
|
+
await fileSystem().read(
|
|
263
|
+
path,
|
|
264
|
+
chunkPlaintextLength(header, index) + TAG_BYTES,
|
|
265
|
+
chunkOffset(header, index),
|
|
266
|
+
'base64',
|
|
267
|
+
),
|
|
268
|
+
'base64',
|
|
269
|
+
);
|
|
270
|
+
const decrypted = decryptChunk(key, index, stored);
|
|
271
|
+
const chunkStart = index * header.chunkSize;
|
|
272
|
+
pieces.push(
|
|
273
|
+
decrypted.subarray(
|
|
274
|
+
Math.max(0, from - chunkStart),
|
|
275
|
+
Math.min(decrypted.length, to - chunkStart),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return Buffer.concat(pieces).toString('base64');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function readArtifact(
|
|
284
|
+
context: ArtifactContext,
|
|
285
|
+
id: string,
|
|
286
|
+
range?: ByteRange,
|
|
287
|
+
): Promise<string> {
|
|
288
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
289
|
+
const key = context.artifactKey(id);
|
|
290
|
+
try {
|
|
291
|
+
return key ? await readEncrypted(path, key, range) : await readPlaintext(path, range);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// A typed error the package already classified — an unreadable header,
|
|
294
|
+
// a failing tag — says something more specific than "missing" and must
|
|
295
|
+
// survive. Anything else reaching here is the file not being there.
|
|
296
|
+
if (isStorageError(error)) {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
raise(DiagnosticCodes.ATTACHMENT_MISSING, {
|
|
300
|
+
message: 'artifact is missing',
|
|
301
|
+
detail: { attachmentId: id },
|
|
302
|
+
cause: error,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function removeArtifact(
|
|
308
|
+
context: ArtifactContext,
|
|
309
|
+
id: string,
|
|
310
|
+
): Promise<DeletionOutcome> {
|
|
311
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
312
|
+
const removed = await removeIfPresent(path);
|
|
313
|
+
const hadReference = await deleteReference(context.executor(), id);
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
removedRecords: 0,
|
|
317
|
+
removedArtifacts: removed ? [id] : [],
|
|
318
|
+
// A reference with no file left behind is worth reporting rather than
|
|
319
|
+
// rounding up to success (FR-071).
|
|
320
|
+
orphanedReferences: !removed && hadReference ? [id] : [],
|
|
321
|
+
failedRemovals: [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface every storage implementation satisfies.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations exist: op-sqlite on device, and a pure-TypeScript
|
|
5
|
+
* in-memory one for tests. The second is not a convenience — op-sqlite is
|
|
6
|
+
* native and unreachable from Jest, so without it the package's own
|
|
7
|
+
* guarantees would be untestable in CI (research.md R8).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AttachmentAvailability,
|
|
12
|
+
AttachmentInput,
|
|
13
|
+
AttachmentReference,
|
|
14
|
+
AttachmentOwner,
|
|
15
|
+
ByteRange,
|
|
16
|
+
DeletionOutcome,
|
|
17
|
+
ReconciliationReport,
|
|
18
|
+
} from './attachment';
|
|
19
|
+
import type { EffectivePosture } from './backup';
|
|
20
|
+
import type { DatabaseConfig, DatabaseTargetConfig, ScopeTargetConfig } from './config';
|
|
21
|
+
import type { FilterNode, IndexCoverageReport, Query, ResultPage } from './query';
|
|
22
|
+
import type { RecordInput, StorableRecordData, StoredRecord, WriteOutcome } from './record';
|
|
23
|
+
import type { BatchOperation, BatchOutcome, CollectionOps, Transaction } from './transaction';
|
|
24
|
+
|
|
25
|
+
export type Attachments = {
|
|
26
|
+
write(input: AttachmentInput): Promise<AttachmentReference>;
|
|
27
|
+
read(id: string, range?: ByteRange): Promise<string>;
|
|
28
|
+
/** Never opens or decrypts the artifact (FR-073e, FR-107b). */
|
|
29
|
+
availability(id: string): Promise<AttachmentAvailability>;
|
|
30
|
+
remove(id: string): Promise<DeletionOutcome>;
|
|
31
|
+
listFor(owner: AttachmentOwner): Promise<AttachmentReference[]>;
|
|
32
|
+
/** App-triggered only; the package runs no background sweep. */
|
|
33
|
+
reconcile(options?: { removeOrphans?: boolean }): Promise<ReconciliationReport>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DatabaseHandle = {
|
|
37
|
+
/** The scope hash. Never the components (FR-014). */
|
|
38
|
+
readonly scopeKey: string;
|
|
39
|
+
readonly schemaVersion: number;
|
|
40
|
+
readonly backupPosture: EffectivePosture;
|
|
41
|
+
readonly isEncrypted: boolean;
|
|
42
|
+
|
|
43
|
+
collection<T extends StorableRecordData = StorableRecordData>(
|
|
44
|
+
name: string,
|
|
45
|
+
): CollectionOps<T>;
|
|
46
|
+
transaction<R>(fn: (tx: Transaction) => Promise<R>): Promise<R>;
|
|
47
|
+
batch(operations: BatchOperation[]): Promise<BatchOutcome>;
|
|
48
|
+
attachments(): Attachments;
|
|
49
|
+
/** Development-mode index coverage answer (FR-037). */
|
|
50
|
+
explain(collection: string, query?: Query): Promise<IndexCoverageReport>;
|
|
51
|
+
close(): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What an implementation must provide. Deliberately lower-level than the
|
|
56
|
+
* public surface: validation, budget checks, and error mapping happen once in
|
|
57
|
+
* `adapter/`, above this line, so both implementations share them.
|
|
58
|
+
*/
|
|
59
|
+
export type StorageAdapter = {
|
|
60
|
+
readonly name: 'sqlite' | 'memory';
|
|
61
|
+
|
|
62
|
+
open(config: DatabaseConfig): Promise<DatabaseHandle>;
|
|
63
|
+
deleteScopeData(config: ScopeTargetConfig): Promise<DeletionOutcome>;
|
|
64
|
+
deleteDatabase(config: DatabaseTargetConfig): Promise<DeletionOutcome>;
|
|
65
|
+
/** The FR-057a recovery path. Never reachable from open(). */
|
|
66
|
+
resetScope(config: ScopeTargetConfig): Promise<DeletionOutcome>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type {
|
|
70
|
+
CollectionOps,
|
|
71
|
+
FilterNode,
|
|
72
|
+
Query,
|
|
73
|
+
RecordInput,
|
|
74
|
+
ResultPage,
|
|
75
|
+
StoredRecord,
|
|
76
|
+
Transaction,
|
|
77
|
+
WriteOutcome,
|
|
78
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment references and the artifacts behind them.
|
|
3
|
+
*
|
|
4
|
+
* This package owns both halves. The consequence is that a filesystem write
|
|
5
|
+
* cannot be rolled back by a database transaction, which is why
|
|
6
|
+
* reconciliation exists (FR-073d) rather than being a nicety.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type AttachmentOwner = {
|
|
10
|
+
collection: string;
|
|
11
|
+
id: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AttachmentInput = {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Absent means the attachment belongs to the scope rather than a record. */
|
|
17
|
+
owner?: AttachmentOwner;
|
|
18
|
+
contentType: string;
|
|
19
|
+
/** Base64 content. Written incrementally; never held whole when encrypted. */
|
|
20
|
+
content: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AttachmentReference = {
|
|
24
|
+
id: string;
|
|
25
|
+
owner?: AttachmentOwner;
|
|
26
|
+
contentType: string;
|
|
27
|
+
/** Plaintext byte length, <= 100 MB (FR-088). */
|
|
28
|
+
size: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
/** Present only for encrypted artifacts (research.md R4). */
|
|
31
|
+
chunkSize?: number;
|
|
32
|
+
chunkCount?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `undecryptable` is deliberately distinct from `missing`: one is data loss
|
|
37
|
+
* for that artifact, the other is a wrong key or a tampered file (FR-107c).
|
|
38
|
+
*/
|
|
39
|
+
export type AttachmentAvailabilityState =
|
|
40
|
+
| 'available'
|
|
41
|
+
| 'missing'
|
|
42
|
+
| 'inaccessible'
|
|
43
|
+
| 'undecryptable';
|
|
44
|
+
|
|
45
|
+
export type AttachmentAvailability = {
|
|
46
|
+
id: string;
|
|
47
|
+
state: AttachmentAvailabilityState;
|
|
48
|
+
size?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ByteRange = {
|
|
52
|
+
offset: number;
|
|
53
|
+
length: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Both directions of divergence between the database and the disk (FR-073d).
|
|
58
|
+
*/
|
|
59
|
+
export type ReconciliationReport = {
|
|
60
|
+
orphanedArtifacts: string[];
|
|
61
|
+
danglingReferences: string[];
|
|
62
|
+
removedArtifacts: string[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Never a bare success (FR-065, FR-071). */
|
|
66
|
+
export type DeletionOutcome = {
|
|
67
|
+
scopeKey?: string;
|
|
68
|
+
removedRecords: number;
|
|
69
|
+
removedArtifacts: string[];
|
|
70
|
+
orphanedReferences: string[];
|
|
71
|
+
failedRemovals: string[];
|
|
72
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform backup posture.
|
|
3
|
+
*
|
|
4
|
+
* The platforms are not symmetric, and this contract is where that becomes
|
|
5
|
+
* visible to the consumer rather than hidden. On iOS the package sets the
|
|
6
|
+
* exclusion attribute itself. On Android backup participation is declared in
|
|
7
|
+
* the app's manifest — build-time, app-owned, and unreachable from a library
|
|
8
|
+
* that authors no native code — so the package verifies and reports instead.
|
|
9
|
+
* See research.md R5.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type BackupConfig = {
|
|
13
|
+
/** Default false: excluded unless the app asks otherwise (FR-109). */
|
|
14
|
+
includeInPlatformBackup: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BackupPostureValue = 'excluded' | 'included' | 'unknown';
|
|
18
|
+
|
|
19
|
+
export type EffectivePosture = {
|
|
20
|
+
/** What the app asked for. */
|
|
21
|
+
requested: Exclude<BackupPostureValue, 'unknown'>;
|
|
22
|
+
/** What the package can actually confirm on this platform. */
|
|
23
|
+
observed: BackupPostureValue;
|
|
24
|
+
/** iOS: the package. Android: the app's manifest. */
|
|
25
|
+
enforcedBy: 'package' | 'app-manifest';
|
|
26
|
+
/** Present when requested and observed disagree — the case that matters. */
|
|
27
|
+
warning?: string;
|
|
28
|
+
};
|