@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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database configuration: everything an app supplies at open time.
|
|
3
|
+
*
|
|
4
|
+
* Immutable for the handle's lifetime. Scope in particular is bound here and
|
|
5
|
+
* never changes (FR-008a) — there is no switchScope, and no operation takes a
|
|
6
|
+
* scope argument, which is what makes cross-scope leakage inexpressible
|
|
7
|
+
* rather than merely prevented.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BackupConfig } from './backup';
|
|
11
|
+
import type { EncryptionConfig } from './encryption';
|
|
12
|
+
import type { Migration } from './migration';
|
|
13
|
+
import type { CollectionSchema } from './schema';
|
|
14
|
+
import type { ScopeDescriptor } from './scope';
|
|
15
|
+
|
|
16
|
+
/** Phase-one budgets (FR-088). Confirmed against the harness during validation. */
|
|
17
|
+
export const Budgets = {
|
|
18
|
+
MAX_RECORDS_PER_COLLECTION: 100_000,
|
|
19
|
+
MAX_RECORD_BYTES: 256 * 1024,
|
|
20
|
+
MAX_BATCH_OPERATIONS: 1_000,
|
|
21
|
+
MAX_PAGE_SIZE: 500,
|
|
22
|
+
MAX_COLLECTIONS_PER_DATABASE: 50,
|
|
23
|
+
MAX_ATTACHMENT_REFERENCES_PER_SCOPE: 50_000,
|
|
24
|
+
MAX_ARTIFACT_BYTES: 100 * 1024 * 1024,
|
|
25
|
+
MAX_OPEN_HANDLES: 8,
|
|
26
|
+
/** Plaintext chunk size for encrypted artifacts (research.md R4). */
|
|
27
|
+
ARTIFACT_CHUNK_BYTES: 256 * 1024,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export type DatabaseConfig = {
|
|
31
|
+
name: string;
|
|
32
|
+
scope: ScopeDescriptor;
|
|
33
|
+
/** Must equal the highest version in `migrations` (FR-053). */
|
|
34
|
+
schemaVersion: number;
|
|
35
|
+
collections: CollectionSchema[];
|
|
36
|
+
migrations?: Migration[];
|
|
37
|
+
/** Absent means unencrypted (FR-097). */
|
|
38
|
+
encryption?: EncryptionConfig;
|
|
39
|
+
/** Absent means excluded from platform backup (FR-109). */
|
|
40
|
+
backup?: BackupConfig;
|
|
41
|
+
/** Governs whether deleting a record removes its artifacts (FR-070). */
|
|
42
|
+
cascadeAttachments?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Names a scope for the operations that legitimately need to (FR-059). */
|
|
46
|
+
export type ScopeTargetConfig = {
|
|
47
|
+
name: string;
|
|
48
|
+
scope: ScopeDescriptor;
|
|
49
|
+
encryption?: EncryptionConfig;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type DatabaseTargetConfig = {
|
|
53
|
+
name: string;
|
|
54
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in encryption at rest under an app-supplied key.
|
|
3
|
+
*
|
|
4
|
+
* The package never generates, derives from its own material, persists,
|
|
5
|
+
* caches beyond the handle's lifetime, transmits, or returns a key (FR-098).
|
|
6
|
+
* Keeping key acquisition on the app side is also what keeps a keychain
|
|
7
|
+
* dependency out of this package entirely (LIB-004).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResolvedScope } from './scope';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Implemented by the consuming app. Receives the scope so an app can key per
|
|
14
|
+
* identity — which is what makes destroying one identity's key meaningful
|
|
15
|
+
* (FR-104).
|
|
16
|
+
*/
|
|
17
|
+
export type KeyProvider = {
|
|
18
|
+
getKey(scope: ResolvedScope): Promise<string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type EncryptionConfig = {
|
|
22
|
+
enabled: true;
|
|
23
|
+
keyProvider: KeyProvider;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the package will say about its own at-rest posture. Reported so a
|
|
28
|
+
* consumer can make an accurate privacy disclosure rather than assume one.
|
|
29
|
+
*/
|
|
30
|
+
export type EncryptionPosture = {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
/** Whether SQLCipher is actually compiled into the app (research.md R1a). */
|
|
33
|
+
available: boolean;
|
|
34
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic codes.
|
|
3
|
+
*
|
|
4
|
+
* Stable identifiers so an app can choose a response without parsing a
|
|
5
|
+
* message (FR-074, FR-082). Disk-full, filesystem-access, and corruption stay
|
|
6
|
+
* distinct rather than collapsing into a generic write failure (FR-076) —
|
|
7
|
+
* an app frees space for one, retries after unlock for another, and offers a
|
|
8
|
+
* reset for the third.
|
|
9
|
+
*
|
|
10
|
+
* Full table with recommended recovery per code:
|
|
11
|
+
* specs/008-rn-local-db-package/contracts/errors.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DiagnosticCodes = {
|
|
15
|
+
// Configuration and scope
|
|
16
|
+
CONFIG_INVALID: 'CONFIG_INVALID',
|
|
17
|
+
INVALID_SCOPE: 'INVALID_SCOPE',
|
|
18
|
+
MIGRATION_CONFIG_INVALID: 'MIGRATION_CONFIG_INVALID',
|
|
19
|
+
|
|
20
|
+
// Open and lifecycle
|
|
21
|
+
OPEN_FAILED: 'OPEN_FAILED',
|
|
22
|
+
CLOSED_HANDLE: 'CLOSED_HANDLE',
|
|
23
|
+
SCOPE_DELETED: 'SCOPE_DELETED',
|
|
24
|
+
CORRUPTION: 'CORRUPTION',
|
|
25
|
+
STORAGE_ACCESS: 'STORAGE_ACCESS',
|
|
26
|
+
|
|
27
|
+
// Migration
|
|
28
|
+
MIGRATION_FAILED: 'MIGRATION_FAILED',
|
|
29
|
+
SCHEMA_DOWNGRADE: 'SCHEMA_DOWNGRADE',
|
|
30
|
+
|
|
31
|
+
// Records and writes
|
|
32
|
+
RECORD_NOT_FOUND: 'RECORD_NOT_FOUND',
|
|
33
|
+
RECORD_CONFLICT: 'RECORD_CONFLICT',
|
|
34
|
+
RECORD_TOO_LARGE: 'RECORD_TOO_LARGE',
|
|
35
|
+
BATCH_TOO_LARGE: 'BATCH_TOO_LARGE',
|
|
36
|
+
DISK_FULL: 'DISK_FULL',
|
|
37
|
+
WRITE_FAILED: 'WRITE_FAILED',
|
|
38
|
+
|
|
39
|
+
// Query
|
|
40
|
+
QUERY_INVALID: 'QUERY_INVALID',
|
|
41
|
+
PAGE_SIZE_INVALID: 'PAGE_SIZE_INVALID',
|
|
42
|
+
CURSOR_INVALID: 'CURSOR_INVALID',
|
|
43
|
+
|
|
44
|
+
// Transaction
|
|
45
|
+
TRANSACTION_FAILED: 'TRANSACTION_FAILED',
|
|
46
|
+
TRANSACTION_STATE: 'TRANSACTION_STATE',
|
|
47
|
+
|
|
48
|
+
// Attachments
|
|
49
|
+
ATTACHMENT_MISSING: 'ATTACHMENT_MISSING',
|
|
50
|
+
ATTACHMENT_OUT_OF_BOUNDS: 'ATTACHMENT_OUT_OF_BOUNDS',
|
|
51
|
+
ATTACHMENT_WRITE_FAILED: 'ATTACHMENT_WRITE_FAILED',
|
|
52
|
+
ATTACHMENT_UNDECRYPTABLE: 'ATTACHMENT_UNDECRYPTABLE',
|
|
53
|
+
|
|
54
|
+
// Encryption
|
|
55
|
+
KEY_INVALID: 'KEY_INVALID',
|
|
56
|
+
KEY_PROVIDER_FAILED: 'KEY_PROVIDER_FAILED',
|
|
57
|
+
ENCRYPTION_MISMATCH: 'ENCRYPTION_MISMATCH',
|
|
58
|
+
ENCRYPTION_UNAVAILABLE: 'ENCRYPTION_UNAVAILABLE',
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export type DiagnosticCode = (typeof DiagnosticCodes)[keyof typeof DiagnosticCodes];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Codes worth retrying. `KEY_PROVIDER_FAILED` is here because a locked device
|
|
65
|
+
* is an expected condition, not a bug (FR-102).
|
|
66
|
+
*/
|
|
67
|
+
export const RETRYABLE_CODES: ReadonlySet<DiagnosticCode> = new Set<DiagnosticCode>([
|
|
68
|
+
DiagnosticCodes.OPEN_FAILED,
|
|
69
|
+
DiagnosticCodes.DISK_FULL,
|
|
70
|
+
DiagnosticCodes.WRITE_FAILED,
|
|
71
|
+
DiagnosticCodes.STORAGE_ACCESS,
|
|
72
|
+
DiagnosticCodes.TRANSACTION_FAILED,
|
|
73
|
+
DiagnosticCodes.MIGRATION_FAILED,
|
|
74
|
+
DiagnosticCodes.ATTACHMENT_WRITE_FAILED,
|
|
75
|
+
DiagnosticCodes.KEY_PROVIDER_FAILED,
|
|
76
|
+
]);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned migrations.
|
|
3
|
+
*
|
|
4
|
+
* The package never writes a migration and never alters domain data except
|
|
5
|
+
* through one the domain supplied (FR-056).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Transaction } from './transaction';
|
|
9
|
+
|
|
10
|
+
/** Low-level escape hatch for schema work a migration needs. */
|
|
11
|
+
export type MigrationContext = Transaction & {
|
|
12
|
+
/** Execute a raw statement against the scope's database. */
|
|
13
|
+
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Migration = {
|
|
17
|
+
/** Unique, contiguous, and reaching the declared schemaVersion (FR-053). */
|
|
18
|
+
version: number;
|
|
19
|
+
migrate(context: MigrationContext): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MigrationOutcome = 'applied' | 'failed';
|
|
23
|
+
|
|
24
|
+
export type MigrationLedgerEntry = {
|
|
25
|
+
version: number;
|
|
26
|
+
attemptedAt: number;
|
|
27
|
+
outcome: MigrationOutcome;
|
|
28
|
+
/** A diagnostic code only — never a message, which could carry payload. */
|
|
29
|
+
failureCode?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Attached to a MIGRATION_FAILED error so an app can tell a transient failure
|
|
34
|
+
* from a permanent one and choose retry, reset, or asking the user (FR-057c).
|
|
35
|
+
*/
|
|
36
|
+
export type MigrationFailureDetail = {
|
|
37
|
+
recordedVersion: number;
|
|
38
|
+
failingVersion: number;
|
|
39
|
+
outcome: MigrationOutcome;
|
|
40
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query contract.
|
|
3
|
+
*
|
|
4
|
+
* A query carries no scope, because it cannot address one — scope is bound to
|
|
5
|
+
* the handle and immutable (FR-008a). That absence is the isolation
|
|
6
|
+
* guarantee, not a convention.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StorableRecordData, StoredRecord } from './record';
|
|
10
|
+
|
|
11
|
+
/** Values a filter may compare against. */
|
|
12
|
+
export type FilterValue = string | number | boolean | null;
|
|
13
|
+
|
|
14
|
+
export type ComparisonOperator =
|
|
15
|
+
| 'eq'
|
|
16
|
+
| 'ne'
|
|
17
|
+
| 'lt'
|
|
18
|
+
| 'lte'
|
|
19
|
+
| 'gt'
|
|
20
|
+
| 'gte'
|
|
21
|
+
| 'in'
|
|
22
|
+
| 'nin'
|
|
23
|
+
| 'between'
|
|
24
|
+
| 'isNull'
|
|
25
|
+
| 'isNotNull'
|
|
26
|
+
| 'exists';
|
|
27
|
+
|
|
28
|
+
export type ComparisonNode =
|
|
29
|
+
| { op: 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte'; field: string; value: FilterValue }
|
|
30
|
+
| { op: 'in' | 'nin'; field: string; value: FilterValue[] }
|
|
31
|
+
| { op: 'between'; field: string; value: [FilterValue, FilterValue] }
|
|
32
|
+
| { op: 'isNull' | 'isNotNull'; field: string }
|
|
33
|
+
/** Distinguishes an absent field from one holding null (FR-019). */
|
|
34
|
+
| { op: 'exists'; field: string; value: boolean };
|
|
35
|
+
|
|
36
|
+
export type LogicalNode =
|
|
37
|
+
| { op: 'and'; nodes: FilterNode[] }
|
|
38
|
+
| { op: 'or'; nodes: FilterNode[] }
|
|
39
|
+
| { op: 'not'; node: FilterNode };
|
|
40
|
+
|
|
41
|
+
export type FilterNode = ComparisonNode | LogicalNode;
|
|
42
|
+
|
|
43
|
+
export type SortDirection = 'asc' | 'desc';
|
|
44
|
+
|
|
45
|
+
export type SortKey = {
|
|
46
|
+
field: string;
|
|
47
|
+
direction?: SortDirection;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PageRequest = {
|
|
51
|
+
/** 1..500 (FR-036, FR-088). */
|
|
52
|
+
size: number;
|
|
53
|
+
/** Opaque keyset cursor from a previous page. */
|
|
54
|
+
cursor?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type Query = {
|
|
58
|
+
filter?: FilterNode;
|
|
59
|
+
sort?: SortKey[];
|
|
60
|
+
page?: PageRequest;
|
|
61
|
+
/** Soft-deleted records are excluded unless this is true (FR-021). */
|
|
62
|
+
includeSoftDeleted?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ResultPage<T extends StorableRecordData = StorableRecordData> = {
|
|
66
|
+
records: StoredRecord<T>[];
|
|
67
|
+
/** Absent when there is no further page. */
|
|
68
|
+
cursor?: string;
|
|
69
|
+
hasMore: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Development-only answer to "is this query covered by an index?" (FR-037).
|
|
74
|
+
* Backed by EXPLAIN QUERY PLAN on the SQLite adapter.
|
|
75
|
+
*/
|
|
76
|
+
export type IndexCoverageReport = {
|
|
77
|
+
collection: string;
|
|
78
|
+
usesIndex: boolean;
|
|
79
|
+
indexName?: string;
|
|
80
|
+
detail: string;
|
|
81
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records and the four value conditions.
|
|
3
|
+
*
|
|
4
|
+
* The distinction FR-019 requires — record absent, field explicitly null,
|
|
5
|
+
* field absent, record soft-deleted — is preserved because reads reconstruct
|
|
6
|
+
* from the stored payload alone and never from projected columns. See
|
|
7
|
+
* research.md R6.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Anything the package will store and hand back unchanged. */
|
|
11
|
+
export type StorableValue =
|
|
12
|
+
| string
|
|
13
|
+
| number
|
|
14
|
+
| boolean
|
|
15
|
+
| null
|
|
16
|
+
| StorableValue[]
|
|
17
|
+
| { [key: string]: StorableValue };
|
|
18
|
+
|
|
19
|
+
export type StorableRecordData = { [key: string]: StorableValue };
|
|
20
|
+
|
|
21
|
+
export type RecordInput<T extends StorableRecordData = StorableRecordData> = {
|
|
22
|
+
id: string;
|
|
23
|
+
data: T;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StoredRecord<T extends StorableRecordData = StorableRecordData> = {
|
|
27
|
+
id: string;
|
|
28
|
+
data: T;
|
|
29
|
+
/** Present only when the collection declares timestamps (FR-020). */
|
|
30
|
+
createdAt?: number;
|
|
31
|
+
updatedAt?: number;
|
|
32
|
+
/** Present only when soft-deleted (FR-021). */
|
|
33
|
+
deletedAt?: number | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type WriteOperation = 'created' | 'updated' | 'deleted' | 'restored';
|
|
37
|
+
|
|
38
|
+
/** An upsert reports which it actually was (FR-026). */
|
|
39
|
+
export type WriteOutcome = {
|
|
40
|
+
operation: WriteOperation;
|
|
41
|
+
id: string;
|
|
42
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection schema: structure only.
|
|
3
|
+
*
|
|
4
|
+
* Declaring a field makes it queryable and projectable. It does NOT restrict
|
|
5
|
+
* what a record may contain — undeclared fields are stored and returned
|
|
6
|
+
* faithfully, they are simply not filterable or sortable (FR-017, FR-034).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type FieldType = 'string' | 'number' | 'boolean' | 'timestamp';
|
|
10
|
+
|
|
11
|
+
export type FieldDeclaration = {
|
|
12
|
+
name: string;
|
|
13
|
+
type: FieldType;
|
|
14
|
+
/** Whether the projected column tolerates SQL NULL. Purely a query concern. */
|
|
15
|
+
nullable?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type IndexDeclaration = {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Must all reference declared fields (FR-006). */
|
|
21
|
+
fields: string[];
|
|
22
|
+
unique?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CollectionSchema = {
|
|
26
|
+
name: string;
|
|
27
|
+
fields: FieldDeclaration[];
|
|
28
|
+
indexes?: IndexDeclaration[];
|
|
29
|
+
/**
|
|
30
|
+
* Opt-in per collection, off by default. When on, deleted rows are
|
|
31
|
+
* excluded from queries unless explicitly requested (FR-021).
|
|
32
|
+
*/
|
|
33
|
+
softDelete?: boolean;
|
|
34
|
+
/** When on, the package maintains createdAt / updatedAt (FR-020). */
|
|
35
|
+
timestamps?: boolean;
|
|
36
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage scope: the isolation boundary for all data.
|
|
3
|
+
*
|
|
4
|
+
* Components are opaque values the consuming app supplies. The package
|
|
5
|
+
* composes, normalizes, and validates them but never interprets them, and
|
|
6
|
+
* never invents one of its own (FR-008, FR-009).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ScopeKind = 'guest' | 'identity' | 'tenant' | 'workspace' | 'custom';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an app supplies to name a scope.
|
|
13
|
+
*
|
|
14
|
+
* Every component is treated as sensitive (FR-014): none of these values
|
|
15
|
+
* reaches disk, a log, or an error. Only the SHA-256 digest of the canonical
|
|
16
|
+
* form does.
|
|
17
|
+
*/
|
|
18
|
+
export type ScopeDescriptor = {
|
|
19
|
+
kind: ScopeKind;
|
|
20
|
+
/** Required for every kind except `guest`. */
|
|
21
|
+
id?: string;
|
|
22
|
+
tenant?: string;
|
|
23
|
+
app?: string;
|
|
24
|
+
environment?: string;
|
|
25
|
+
namespace?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The resolved scope: a hex digest, plus the kind it came from. */
|
|
29
|
+
export type ResolvedScope = {
|
|
30
|
+
/** SHA-256 hex of the canonical descriptor. Names the scope's directory. */
|
|
31
|
+
key: string;
|
|
32
|
+
kind: ScopeKind;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Where a scope sits in its lifecycle. Persisted in the catalog (FR-063). */
|
|
36
|
+
export type ScopeDeletionState = 'active' | 'deleting';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactions and batches.
|
|
3
|
+
*
|
|
4
|
+
* A transaction never spans two scopes (FR-038): with one database file per
|
|
5
|
+
* scope, a cross-scope transaction is not expressible rather than merely
|
|
6
|
+
* discouraged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FilterNode, Query, ResultPage } from './query';
|
|
10
|
+
import type { RecordInput, StorableRecordData, StoredRecord, WriteOutcome } from './record';
|
|
11
|
+
|
|
12
|
+
/** Collection operations, available on a handle and inside a transaction. */
|
|
13
|
+
export type CollectionOps<T extends StorableRecordData = StorableRecordData> = {
|
|
14
|
+
insert(record: RecordInput<T>): Promise<WriteOutcome>;
|
|
15
|
+
update(id: string, record: Partial<RecordInput<T>> & { data: T }): Promise<WriteOutcome>;
|
|
16
|
+
upsert(record: RecordInput<T>): Promise<WriteOutcome>;
|
|
17
|
+
/** Rejects with RECORD_NOT_FOUND when absent (FR-027). */
|
|
18
|
+
get(id: string): Promise<StoredRecord<T>>;
|
|
19
|
+
/** Null-returning variant, so absence stays distinguishable at the call site. */
|
|
20
|
+
find(id: string): Promise<StoredRecord<T> | null>;
|
|
21
|
+
delete(id: string): Promise<WriteOutcome>;
|
|
22
|
+
softDelete(id: string): Promise<WriteOutcome>;
|
|
23
|
+
restore(id: string): Promise<WriteOutcome>;
|
|
24
|
+
list(query?: Query): Promise<ResultPage<T>>;
|
|
25
|
+
count(filter?: FilterNode): Promise<number>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Transaction = {
|
|
29
|
+
collection<T extends StorableRecordData = StorableRecordData>(
|
|
30
|
+
name: string,
|
|
31
|
+
): CollectionOps<T>;
|
|
32
|
+
/** Discards every write in this transaction (FR-041). */
|
|
33
|
+
abort(): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type BatchOperation =
|
|
37
|
+
| { type: 'insert'; collection: string; record: RecordInput }
|
|
38
|
+
| { type: 'update'; collection: string; id: string; data: StorableRecordData }
|
|
39
|
+
| { type: 'upsert'; collection: string; record: RecordInput }
|
|
40
|
+
| { type: 'delete'; collection: string; id: string }
|
|
41
|
+
| { type: 'softDelete'; collection: string; id: string };
|
|
42
|
+
|
|
43
|
+
export type BatchOutcome = {
|
|
44
|
+
applied: number;
|
|
45
|
+
outcomes: WriteOutcome[];
|
|
46
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chunked AES-256-GCM artifact format.
|
|
3
|
+
*
|
|
4
|
+
* Whole-file encryption was rejected because a 100 MB artifact would have to
|
|
5
|
+
* be held in memory twice — plaintext and ciphertext — which breaks FR-107a's
|
|
6
|
+
* flat memory bound and every PERF-001 budget along with it (research.md R4).
|
|
7
|
+
* Chunking keeps exactly one 256 KB chunk in flight regardless of artifact
|
|
8
|
+
* size.
|
|
9
|
+
*
|
|
10
|
+
* ```text
|
|
11
|
+
* [ header 22 bytes ] magic | format version | chunk size | chunk count | plaintext size
|
|
12
|
+
* [ chunk 0 ] ciphertext(≤ chunkSize) | auth tag (16)
|
|
13
|
+
* [ chunk 1 ] …
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The auth tag follows each chunk rather than sitting in the header, which is
|
|
17
|
+
* a deliberate departure from the sketch in storage-layout.md. A tag is only
|
|
18
|
+
* known after its chunk has been encrypted, and the filesystem peer appends —
|
|
19
|
+
* it cannot seek back to patch a header. Collecting every tag before writing
|
|
20
|
+
* anything would mean buffering the whole ciphertext, which is the thing this
|
|
21
|
+
* format exists to avoid. Inline tags keep the write append-only and leave
|
|
22
|
+
* every chunk at a computable offset.
|
|
23
|
+
*
|
|
24
|
+
* A failing tag on read is `ATTACHMENT_UNDECRYPTABLE`, deliberately distinct
|
|
25
|
+
* from `ATTACHMENT_MISSING` (FR-107c): one is a wrong key or a tampered file
|
|
26
|
+
* and may be recoverable, the other is data that is simply gone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Budgets } from '../contract/config';
|
|
30
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
31
|
+
import { raise } from '../diagnostics/errors';
|
|
32
|
+
import { nonceForChunk } from './derive';
|
|
33
|
+
import { cipherModule } from './provider';
|
|
34
|
+
|
|
35
|
+
export const MAGIC = 'LDB1';
|
|
36
|
+
export const FORMAT_VERSION = 1;
|
|
37
|
+
export const HEADER_BYTES = 22;
|
|
38
|
+
export const TAG_BYTES = 16;
|
|
39
|
+
export const ALGORITHM = 'aes-256-gcm';
|
|
40
|
+
|
|
41
|
+
export type ArtifactHeader = {
|
|
42
|
+
formatVersion: number;
|
|
43
|
+
chunkSize: number;
|
|
44
|
+
chunkCount: number;
|
|
45
|
+
plaintextSize: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function chunkCountFor(plaintextSize: number, chunkSize: number): number {
|
|
49
|
+
return plaintextSize === 0 ? 0 : Math.ceil(plaintextSize / chunkSize);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function encodeHeader(header: ArtifactHeader): Buffer {
|
|
53
|
+
const buffer = Buffer.alloc(HEADER_BYTES);
|
|
54
|
+
buffer.write(MAGIC, 0, 4, 'ascii');
|
|
55
|
+
buffer.writeUInt16BE(header.formatVersion, 4);
|
|
56
|
+
buffer.writeUInt32BE(header.chunkSize, 6);
|
|
57
|
+
buffer.writeUInt32BE(header.chunkCount, 10);
|
|
58
|
+
// Split across two 32-bit writes: artifact sizes stay well inside the safe
|
|
59
|
+
// integer range, and this avoids depending on BigInt in Hermes.
|
|
60
|
+
buffer.writeUInt32BE(Math.floor(header.plaintextSize / 0x100000000), 14);
|
|
61
|
+
buffer.writeUInt32BE(header.plaintextSize % 0x100000000, 18);
|
|
62
|
+
return buffer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function decodeHeader(buffer: Buffer): ArtifactHeader {
|
|
66
|
+
if (buffer.length < HEADER_BYTES || buffer.toString('ascii', 0, 4) !== MAGIC) {
|
|
67
|
+
raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
|
|
68
|
+
message: 'artifact header is not a recognised encrypted artifact',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
formatVersion: buffer.readUInt16BE(4),
|
|
73
|
+
chunkSize: buffer.readUInt32BE(6),
|
|
74
|
+
chunkCount: buffer.readUInt32BE(10),
|
|
75
|
+
plaintextSize: buffer.readUInt32BE(14) * 0x100000000 + buffer.readUInt32BE(18),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Plaintext length of chunk `index`; the last one is usually short. */
|
|
80
|
+
export function chunkPlaintextLength(header: ArtifactHeader, index: number): number {
|
|
81
|
+
const start = index * header.chunkSize;
|
|
82
|
+
return Math.max(0, Math.min(header.chunkSize, header.plaintextSize - start));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Byte offset of chunk `index` in the file.
|
|
87
|
+
*
|
|
88
|
+
* Every chunk but the last is full, so the offset is arithmetic rather than a
|
|
89
|
+
* table lookup — which is what lets a range read seek straight to the chunk
|
|
90
|
+
* it needs instead of walking the file.
|
|
91
|
+
*/
|
|
92
|
+
export function chunkOffset(header: ArtifactHeader, index: number): number {
|
|
93
|
+
return HEADER_BYTES + index * (header.chunkSize + TAG_BYTES);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function encryptedLength(header: ArtifactHeader): number {
|
|
97
|
+
if (header.chunkCount === 0) {
|
|
98
|
+
return HEADER_BYTES;
|
|
99
|
+
}
|
|
100
|
+
const lastIndex = header.chunkCount - 1;
|
|
101
|
+
return chunkOffset(header, lastIndex) + chunkPlaintextLength(header, lastIndex) + TAG_BYTES;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One chunk in, ciphertext with its tag appended out. */
|
|
105
|
+
export function encryptChunk(key: Uint8Array, index: number, plaintext: Buffer): Buffer {
|
|
106
|
+
const cipher = cipherModule().createCipheriv(ALGORITHM, key, nonceForChunk(index));
|
|
107
|
+
const body = Buffer.concat([
|
|
108
|
+
Buffer.from(cipher.update(plaintext)),
|
|
109
|
+
Buffer.from(cipher.final()),
|
|
110
|
+
]);
|
|
111
|
+
return Buffer.concat([body, Buffer.from(cipher.getAuthTag())]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function decryptChunk(key: Uint8Array, index: number, stored: Buffer): Buffer {
|
|
115
|
+
if (stored.length < TAG_BYTES) {
|
|
116
|
+
raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
|
|
117
|
+
message: 'artifact chunk is truncated',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const body = stored.subarray(0, stored.length - TAG_BYTES);
|
|
121
|
+
const tag = stored.subarray(stored.length - TAG_BYTES);
|
|
122
|
+
|
|
123
|
+
const decipher = cipherModule().createDecipheriv(ALGORITHM, key, nonceForChunk(index));
|
|
124
|
+
decipher.setAuthTag(tag);
|
|
125
|
+
try {
|
|
126
|
+
return Buffer.concat([Buffer.from(decipher.update(body)), Buffer.from(decipher.final())]);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// A failing tag is the only signal that separates "exists but will not
|
|
129
|
+
// decrypt" from "missing", and an app responds to them differently.
|
|
130
|
+
raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
|
|
131
|
+
message: 'artifact chunk failed authentication',
|
|
132
|
+
cause: error,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function headerFor(plaintextSize: number, chunkSize = Budgets.ARTIFACT_CHUNK_BYTES): ArtifactHeader {
|
|
138
|
+
return {
|
|
139
|
+
formatVersion: FORMAT_VERSION,
|
|
140
|
+
chunkSize,
|
|
141
|
+
chunkCount: chunkCountFor(plaintextSize, chunkSize),
|
|
142
|
+
plaintextSize,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-artifact key derivation.
|
|
3
|
+
*
|
|
4
|
+
* The database key is handed straight to SQLCipher. Using that same material
|
|
5
|
+
* as the AES-GCM key for artifacts would mean the two keystreams share a
|
|
6
|
+
* secret, so a weakness in either one — a nonce collision in the artifact
|
|
7
|
+
* format, a padding oracle in the database format — becomes a weakness in
|
|
8
|
+
* both. HKDF costs one hash and removes the coupling entirely (research.md
|
|
9
|
+
* R4).
|
|
10
|
+
*
|
|
11
|
+
* The artifact id is the HKDF `info`, so two artifacts under the same
|
|
12
|
+
* database key derive different subkeys. That is what makes the deterministic
|
|
13
|
+
* nonce scheme safe: a nonce is only ever reused with a different key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cipherModule } from './provider';
|
|
17
|
+
|
|
18
|
+
const HKDF_DIGEST = 'sha256';
|
|
19
|
+
const KEY_BYTES = 32;
|
|
20
|
+
const NONCE_BYTES = 12;
|
|
21
|
+
|
|
22
|
+
function bytesOf(value: string): Uint8Array {
|
|
23
|
+
return Uint8Array.from(Buffer.from(value, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 32 bytes, one artifact, one database key. */
|
|
27
|
+
export function deriveArtifactKey(
|
|
28
|
+
databaseKey: string,
|
|
29
|
+
scopeKey: string,
|
|
30
|
+
attachmentId: string,
|
|
31
|
+
): Uint8Array {
|
|
32
|
+
const derived = cipherModule().hkdfSync(
|
|
33
|
+
HKDF_DIGEST,
|
|
34
|
+
bytesOf(databaseKey),
|
|
35
|
+
// The scope digest as salt keeps two scopes holding an artifact under
|
|
36
|
+
// the same id from deriving the same subkey.
|
|
37
|
+
bytesOf(scopeKey),
|
|
38
|
+
bytesOf(`chipmobilesdk-localdb/artifact/${attachmentId}`),
|
|
39
|
+
KEY_BYTES,
|
|
40
|
+
);
|
|
41
|
+
return new Uint8Array(derived);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deterministic 12-byte nonce from the chunk index.
|
|
46
|
+
*
|
|
47
|
+
* Deterministic rather than random because a random nonce would have to be
|
|
48
|
+
* stored per chunk, and because uniqueness is already guaranteed one level
|
|
49
|
+
* up: the key is unique per artifact, so an index that is unique within the
|
|
50
|
+
* artifact is unique under that key. The remaining four bytes are a fixed
|
|
51
|
+
* domain tag rather than padding, so a nonce from this scheme can never
|
|
52
|
+
* collide with one from a future scheme that uses the same counter space.
|
|
53
|
+
*/
|
|
54
|
+
export function nonceForChunk(index: number): Uint8Array {
|
|
55
|
+
const nonce = new Uint8Array(NONCE_BYTES);
|
|
56
|
+
const view = new DataView(nonce.buffer);
|
|
57
|
+
view.setUint32(0, 0x4c444231, false); // "LDB1"
|
|
58
|
+
// Split across two 32-bit writes rather than a BigInt: chunk indexes stay
|
|
59
|
+
// far inside the safe integer range, and Hermes is happier without one.
|
|
60
|
+
view.setUint32(4, Math.floor(index / 0x100000000), false);
|
|
61
|
+
view.setUint32(8, index % 0x100000000, false);
|
|
62
|
+
return nonce;
|
|
63
|
+
}
|