@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,65 +1,71 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `Transaction` a consumer actually holds.
|
|
3
|
-
*
|
|
4
|
-
* Two rules live here rather than in either adapter, so both are held to the
|
|
5
|
-
* same standard:
|
|
6
|
-
*
|
|
7
|
-
* **Read-your-own-writes** (FR-043). Every collection reached through `tx`
|
|
8
|
-
* runs against the transaction's executor, so a read inside sees the writes
|
|
9
|
-
* made inside. A collection reached through the handle during a transaction
|
|
10
|
-
* would not, and handing one out would be a quiet correctness trap.
|
|
11
|
-
*
|
|
12
|
-
* **Terminal state is terminal** (FR-044). After a commit or an abort, using
|
|
13
|
-
* the transaction raises rather than silently writing outside it — the
|
|
14
|
-
* failure mode where a caller's cleanup code lands in the database after the
|
|
15
|
-
* rollback it was supposed to be part of.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
19
|
-
import type { StorableRecordData } from '../contract/record';
|
|
20
|
-
import type { CollectionOps, Transaction } from '../contract/transaction';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `Transaction` a consumer actually holds.
|
|
3
|
+
*
|
|
4
|
+
* Two rules live here rather than in either adapter, so both are held to the
|
|
5
|
+
* same standard:
|
|
6
|
+
*
|
|
7
|
+
* **Read-your-own-writes** (FR-043). Every collection reached through `tx`
|
|
8
|
+
* runs against the transaction's executor, so a read inside sees the writes
|
|
9
|
+
* made inside. A collection reached through the handle during a transaction
|
|
10
|
+
* would not, and handing one out would be a quiet correctness trap.
|
|
11
|
+
*
|
|
12
|
+
* **Terminal state is terminal** (FR-044). After a commit or an abort, using
|
|
13
|
+
* the transaction raises rather than silently writing outside it — the
|
|
14
|
+
* failure mode where a caller's cleanup code lands in the database after the
|
|
15
|
+
* rollback it was supposed to be part of.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
19
|
+
import type { StorableRecordData } from '../contract/record';
|
|
20
|
+
import type { CollectionOps, Transaction } from '../contract/transaction';
|
|
21
|
+
import type { Attachments } from '../contract/adapter';
|
|
22
|
+
import { raise } from '../diagnostics/errors';
|
|
23
|
+
|
|
24
|
+
export type TransactionHost = {
|
|
25
|
+
collection<T extends StorableRecordData>(name: string): CollectionOps<T>;
|
|
26
|
+
attachments(): Attachments;
|
|
27
|
+
rollback(): Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ManagedTransaction = {
|
|
31
|
+
transaction: Transaction;
|
|
32
|
+
/** True once the body committed or aborted. */
|
|
33
|
+
isSettled(): boolean;
|
|
34
|
+
settle(): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createTransaction(host: TransactionHost): ManagedTransaction {
|
|
38
|
+
let settled = false;
|
|
39
|
+
|
|
40
|
+
const assertLive = (): void => {
|
|
41
|
+
if (settled) {
|
|
42
|
+
raise(DiagnosticCodes.TRANSACTION_STATE, {
|
|
43
|
+
message: 'transaction already settled',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const transaction: Transaction = {
|
|
49
|
+
collection<T extends StorableRecordData>(name: string): CollectionOps<T> {
|
|
50
|
+
assertLive();
|
|
51
|
+
return host.collection<T>(name);
|
|
52
|
+
},
|
|
53
|
+
attachments(): Attachments {
|
|
54
|
+
assertLive();
|
|
55
|
+
return host.attachments();
|
|
56
|
+
},
|
|
57
|
+
async abort(): Promise<void> {
|
|
58
|
+
assertLive();
|
|
59
|
+
settled = true;
|
|
60
|
+
await host.rollback();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
transaction,
|
|
66
|
+
isSettled: () => settled,
|
|
67
|
+
settle: () => {
|
|
68
|
+
settled = true;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -1,69 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Is this artifact still there?", answered without opening it.
|
|
3
|
-
*
|
|
4
|
-
* `stat()` and, for an encrypted artifact, the 22-byte header. Never a
|
|
5
|
-
* content byte and never a decryption (FR-073e, FR-107b). An availability
|
|
6
|
-
* check that decrypted would make a gallery screen showing fifty thumbnails
|
|
7
|
-
* decrypt fifty artifacts to answer a question none of them asked, and would
|
|
8
|
-
* put key material in play for an operation that does not need it.
|
|
9
|
-
*
|
|
10
|
-
* The four states are deliberately four rather than two. `missing` is data
|
|
11
|
-
* that is gone; `undecryptable` is data that exists under a key that does not
|
|
12
|
-
* open it, which is often recoverable and always a different conversation to
|
|
13
|
-
* have with the user (FR-107c).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { AttachmentAvailability } from '../contract/attachment';
|
|
17
|
-
import { fileSystem } from '../platform/fs';
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return { id, state: 'available', size: reference.size };
|
|
69
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* "Is this artifact still there?", answered without opening it.
|
|
3
|
+
*
|
|
4
|
+
* `stat()` and, for an encrypted artifact, the 22-byte header. Never a
|
|
5
|
+
* content byte and never a decryption (FR-073e, FR-107b). An availability
|
|
6
|
+
* check that decrypted would make a gallery screen showing fifty thumbnails
|
|
7
|
+
* decrypt fifty artifacts to answer a question none of them asked, and would
|
|
8
|
+
* put key material in play for an operation that does not need it.
|
|
9
|
+
*
|
|
10
|
+
* The four states are deliberately four rather than two. `missing` is data
|
|
11
|
+
* that is gone; `undecryptable` is data that exists under a key that does not
|
|
12
|
+
* open it, which is often recoverable and always a different conversation to
|
|
13
|
+
* have with the user (FR-107c).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AttachmentAvailability } from '../contract/attachment';
|
|
17
|
+
import { fileSystem } from '../platform/fs';
|
|
18
|
+
import { asciiAt, base64ToBytes } from '../platform/bytes';
|
|
19
|
+
import { HEADER_BYTES, MAGIC } from '../crypto/chunked-cipher';
|
|
20
|
+
import type { Executor } from '../sqlite/connection';
|
|
21
|
+
import { artifactPath } from './paths';
|
|
22
|
+
import { readReference, type ArtifactContext } from './store';
|
|
23
|
+
|
|
24
|
+
export async function artifactAvailability(
|
|
25
|
+
context: ArtifactContext,
|
|
26
|
+
executor: Executor,
|
|
27
|
+
id: string,
|
|
28
|
+
): Promise<AttachmentAvailability> {
|
|
29
|
+
const reference = await readReference(executor, id);
|
|
30
|
+
if (!reference) {
|
|
31
|
+
// No reference is "missing" rather than an error: the caller asked
|
|
32
|
+
// about an artifact this scope does not claim to have.
|
|
33
|
+
return { id, state: 'missing' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
37
|
+
|
|
38
|
+
let size: number;
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fileSystem().stat(path);
|
|
41
|
+
if (!stat.isFile()) {
|
|
42
|
+
return { id, state: 'inaccessible' };
|
|
43
|
+
}
|
|
44
|
+
size = stat.size;
|
|
45
|
+
} catch {
|
|
46
|
+
return { id, state: 'missing' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (context.artifactKey(id) === null) {
|
|
50
|
+
return { id, state: 'available', size: reference.size };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (size < HEADER_BYTES) {
|
|
54
|
+
return { id, state: 'undecryptable', size: reference.size };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const header = base64ToBytes(await fileSystem().read(path, HEADER_BYTES, 0, 'base64'));
|
|
59
|
+
if (asciiAt(header, 0, 4) !== MAGIC) {
|
|
60
|
+
return { id, state: 'undecryptable', size: reference.size };
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return { id, state: 'inaccessible', size: reference.size };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { id, state: 'available', size: reference.size };
|
|
67
|
+
}
|
package/src/artifacts/cascade.ts
CHANGED
|
@@ -1,78 +1,114 @@
|
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
): Promise<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 { fileSystem, 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
|
+
type DeferredUnlinks,
|
|
26
|
+
} from './store';
|
|
27
|
+
|
|
28
|
+
export type { DeferredUnlinks };
|
|
29
|
+
|
|
30
|
+
/** Unlinks one artifact file. True when the file is gone afterwards, whoever removed it. */
|
|
31
|
+
export async function unlinkArtifact(path: string): Promise<boolean> {
|
|
32
|
+
if (await removeIfPresent(path)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
// `removeIfPresent` cannot tell "already gone" from "could not remove". A
|
|
36
|
+
// file that was already gone counts as removed: the end state the caller
|
|
37
|
+
// asked for is the end state they got.
|
|
38
|
+
try {
|
|
39
|
+
return !(await fileSystem().exists(path));
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function removeAll(
|
|
46
|
+
context: ArtifactContext,
|
|
47
|
+
executor: Executor,
|
|
48
|
+
ids: string[],
|
|
49
|
+
deferred?: DeferredUnlinks,
|
|
50
|
+
): Promise<DeletionOutcome> {
|
|
51
|
+
const removedArtifacts: string[] = [];
|
|
52
|
+
const failedRemovals: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const id of ids) {
|
|
55
|
+
// The reference first. A reference left pointing at a deleted file is
|
|
56
|
+
// the unrecoverable state; a file left without its reference is an
|
|
57
|
+
// orphan reconciliation finds.
|
|
58
|
+
if (!(await deleteReference(executor, id))) {
|
|
59
|
+
failedRemovals.push(id);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
63
|
+
if (deferred) {
|
|
64
|
+
deferred.add(path);
|
|
65
|
+
removedArtifacts.push(id);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (await unlinkArtifact(path)) {
|
|
69
|
+
removedArtifacts.push(id);
|
|
70
|
+
} else {
|
|
71
|
+
// Still on disk holding the user's data: reported, never claimed removed.
|
|
72
|
+
failedRemovals.push(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
removedRecords: 0,
|
|
78
|
+
removedArtifacts,
|
|
79
|
+
orphanedReferences: [],
|
|
80
|
+
failedRemovals,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Everything belonging to one record, for the FR-070 cascade. Inside a
|
|
86
|
+
* transaction pass `deferred`: the files are removed only once it commits.
|
|
87
|
+
*/
|
|
88
|
+
export async function cascadeForOwner(
|
|
89
|
+
context: ArtifactContext,
|
|
90
|
+
executor: Executor,
|
|
91
|
+
owner: AttachmentOwner,
|
|
92
|
+
deferred?: DeferredUnlinks,
|
|
93
|
+
): Promise<DeletionOutcome> {
|
|
94
|
+
const references = await listReferencesFor(executor, owner);
|
|
95
|
+
return removeAll(
|
|
96
|
+
context,
|
|
97
|
+
executor,
|
|
98
|
+
references.map(reference => reference.id),
|
|
99
|
+
deferred,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Everything in the scope, for scope deletion. */
|
|
104
|
+
export async function cascadeAll(
|
|
105
|
+
context: ArtifactContext,
|
|
106
|
+
executor: Executor,
|
|
107
|
+
): Promise<DeletionOutcome> {
|
|
108
|
+
const references = await listReferences(executor);
|
|
109
|
+
return removeAll(
|
|
110
|
+
context,
|
|
111
|
+
executor,
|
|
112
|
+
references.map(reference => reference.id),
|
|
113
|
+
);
|
|
114
|
+
}
|
package/src/artifacts/paths.ts
CHANGED
|
@@ -1,29 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,63 +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
|
-
}
|
|
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
|
+
}
|