@chipmobilesdk/rn-local-db 0.1.2 → 0.2.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 +50 -0
- package/README.md +74 -72
- package/package.json +1 -1
- package/src/adapter/deletion.ts +24 -10
- package/src/adapter/lifecycle.ts +121 -45
- package/src/adapter/transaction.ts +6 -0
- package/src/artifacts/availability.ts +3 -5
- package/src/artifacts/cascade.ts +42 -6
- package/src/artifacts/store.ts +34 -12
- package/src/contract/attachment.ts +6 -0
- package/src/contract/migration.ts +3 -1
- package/src/contract/record.ts +9 -0
- package/src/contract/transaction.ts +8 -0
- package/src/crypto/chunked-cipher.ts +28 -22
- package/src/crypto/derive.ts +2 -1
- package/src/platform/bytes.ts +144 -0
- package/src/query/budgets.ts +2 -5
- package/src/query/cursor.ts +3 -2
- package/src/sqlite/batch.ts +28 -18
- package/src/sqlite/connection.ts +66 -44
- package/src/sqlite/index-coverage.ts +4 -1
- package/src/sqlite/row-codec.ts +23 -3
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/testing/conformance/attachments.ts +1 -1
- package/src/testing/memory-adapter/index.ts +176 -72
package/src/artifacts/cascade.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { AttachmentOwner, DeletionOutcome } from '../contract/attachment';
|
|
17
|
-
import { removeIfPresent } from '../platform/fs';
|
|
17
|
+
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
18
18
|
import type { Executor } from '../sqlite/connection';
|
|
19
19
|
import { artifactPath } from './paths';
|
|
20
20
|
import {
|
|
@@ -22,23 +22,54 @@ import {
|
|
|
22
22
|
listReferences,
|
|
23
23
|
listReferencesFor,
|
|
24
24
|
type ArtifactContext,
|
|
25
|
+
type DeferredUnlinks,
|
|
25
26
|
} from './store';
|
|
26
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
|
+
|
|
27
45
|
async function removeAll(
|
|
28
46
|
context: ArtifactContext,
|
|
29
47
|
executor: Executor,
|
|
30
48
|
ids: string[],
|
|
49
|
+
deferred?: DeferredUnlinks,
|
|
31
50
|
): Promise<DeletionOutcome> {
|
|
32
51
|
const removedArtifacts: string[] = [];
|
|
33
52
|
const failedRemovals: string[] = [];
|
|
34
53
|
|
|
35
54
|
for (const id of ids) {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
removedArtifacts.push(id);
|
|
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.
|
|
40
58
|
if (!(await deleteReference(executor, id))) {
|
|
41
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);
|
|
42
73
|
}
|
|
43
74
|
}
|
|
44
75
|
|
|
@@ -50,17 +81,22 @@ async function removeAll(
|
|
|
50
81
|
};
|
|
51
82
|
}
|
|
52
83
|
|
|
53
|
-
/**
|
|
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
|
+
*/
|
|
54
88
|
export async function cascadeForOwner(
|
|
55
89
|
context: ArtifactContext,
|
|
56
90
|
executor: Executor,
|
|
57
91
|
owner: AttachmentOwner,
|
|
92
|
+
deferred?: DeferredUnlinks,
|
|
58
93
|
): Promise<DeletionOutcome> {
|
|
59
94
|
const references = await listReferencesFor(executor, owner);
|
|
60
95
|
return removeAll(
|
|
61
96
|
context,
|
|
62
97
|
executor,
|
|
63
98
|
references.map(reference => reference.id),
|
|
99
|
+
deferred,
|
|
64
100
|
);
|
|
65
101
|
}
|
|
66
102
|
|
package/src/artifacts/store.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { isStorageError, raise } from '../diagnostics/errors';
|
|
|
28
28
|
import { throwIfFaulted } from '../diagnostics/faults';
|
|
29
29
|
import { raiseNative } from '../diagnostics/map-native';
|
|
30
30
|
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
31
|
+
import { base64ToBytes, bytesToBase64, concatBytes } from '../platform/bytes';
|
|
31
32
|
import type { StorageLayout } from '../platform/location';
|
|
32
33
|
import { assertArtifactWithinBudget } from '../query/budgets';
|
|
33
34
|
import {
|
|
@@ -166,17 +167,17 @@ async function writePlaintext(path: string, content: string): Promise<void> {
|
|
|
166
167
|
async function writeEncrypted(
|
|
167
168
|
path: string,
|
|
168
169
|
key: Uint8Array,
|
|
169
|
-
plaintext:
|
|
170
|
+
plaintext: Uint8Array,
|
|
170
171
|
): Promise<{ chunkSize: number; chunkCount: number }> {
|
|
171
172
|
const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
|
|
172
|
-
await fileSystem().writeFile(path, encodeHeader(header)
|
|
173
|
+
await fileSystem().writeFile(path, bytesToBase64(encodeHeader(header)), 'base64');
|
|
173
174
|
|
|
174
175
|
for (let index = 0; index < header.chunkCount; index += 1) {
|
|
175
176
|
const start = index * header.chunkSize;
|
|
176
177
|
const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
|
|
177
178
|
await fileSystem().appendFile(
|
|
178
179
|
path,
|
|
179
|
-
encryptChunk(key, index, chunk)
|
|
180
|
+
bytesToBase64(encryptChunk(key, index, chunk)),
|
|
180
181
|
'base64',
|
|
181
182
|
);
|
|
182
183
|
}
|
|
@@ -189,7 +190,7 @@ export async function writeArtifact(
|
|
|
189
190
|
input: AttachmentInput,
|
|
190
191
|
): Promise<AttachmentReference> {
|
|
191
192
|
throwIfFaulted('attachmentWrite');
|
|
192
|
-
const plaintext =
|
|
193
|
+
const plaintext = base64ToBytes(input.content);
|
|
193
194
|
assertArtifactWithinBudget(input.id, plaintext.length);
|
|
194
195
|
|
|
195
196
|
const path = artifactPath(context.layout, context.scopeKey, input.id);
|
|
@@ -241,10 +242,7 @@ async function readEncrypted(
|
|
|
241
242
|
key: Uint8Array,
|
|
242
243
|
range?: ByteRange,
|
|
243
244
|
): Promise<string> {
|
|
244
|
-
const headerBytes =
|
|
245
|
-
await fileSystem().read(path, HEADER_BYTES, 0, 'base64'),
|
|
246
|
-
'base64',
|
|
247
|
-
);
|
|
245
|
+
const headerBytes = base64ToBytes(await fileSystem().read(path, HEADER_BYTES, 0, 'base64'));
|
|
248
246
|
const header = decodeHeader(headerBytes);
|
|
249
247
|
|
|
250
248
|
const from = range?.offset ?? 0;
|
|
@@ -255,17 +253,16 @@ async function readEncrypted(
|
|
|
255
253
|
|
|
256
254
|
const first = Math.floor(from / header.chunkSize);
|
|
257
255
|
const last = Math.floor((to - 1) / header.chunkSize);
|
|
258
|
-
const pieces:
|
|
256
|
+
const pieces: Uint8Array[] = [];
|
|
259
257
|
|
|
260
258
|
for (let index = first; index <= last; index += 1) {
|
|
261
|
-
const stored =
|
|
259
|
+
const stored = base64ToBytes(
|
|
262
260
|
await fileSystem().read(
|
|
263
261
|
path,
|
|
264
262
|
chunkPlaintextLength(header, index) + TAG_BYTES,
|
|
265
263
|
chunkOffset(header, index),
|
|
266
264
|
'base64',
|
|
267
265
|
),
|
|
268
|
-
'base64',
|
|
269
266
|
);
|
|
270
267
|
const decrypted = decryptChunk(key, index, stored);
|
|
271
268
|
const chunkStart = index * header.chunkSize;
|
|
@@ -277,7 +274,7 @@ async function readEncrypted(
|
|
|
277
274
|
);
|
|
278
275
|
}
|
|
279
276
|
|
|
280
|
-
return
|
|
277
|
+
return bytesToBase64(concatBytes(pieces));
|
|
281
278
|
}
|
|
282
279
|
|
|
283
280
|
export async function readArtifact(
|
|
@@ -304,11 +301,36 @@ export async function readArtifact(
|
|
|
304
301
|
}
|
|
305
302
|
}
|
|
306
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Collects file removals until the transaction that deleted their references
|
|
306
|
+
* commits. A file unlinked inside the transaction is gone even if the
|
|
307
|
+
* transaction then rolls back and the reference returns — a dangling
|
|
308
|
+
* reference to data nobody can recover.
|
|
309
|
+
*/
|
|
310
|
+
export type DeferredUnlinks = {
|
|
311
|
+
add(path: string): void;
|
|
312
|
+
};
|
|
313
|
+
|
|
307
314
|
export async function removeArtifact(
|
|
308
315
|
context: ArtifactContext,
|
|
309
316
|
id: string,
|
|
317
|
+
deferred?: DeferredUnlinks,
|
|
310
318
|
): Promise<DeletionOutcome> {
|
|
311
319
|
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
320
|
+
if (deferred) {
|
|
321
|
+
// Inside a transaction: the reference goes now and rolls back with it;
|
|
322
|
+
// the file goes once the transaction commits.
|
|
323
|
+
const hadReference = await deleteReference(context.executor(), id);
|
|
324
|
+
if (hadReference) {
|
|
325
|
+
deferred.add(path);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
removedRecords: 0,
|
|
329
|
+
removedArtifacts: hadReference ? [id] : [],
|
|
330
|
+
orphanedReferences: [],
|
|
331
|
+
failedRemovals: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
312
334
|
const removed = await removeIfPresent(path);
|
|
313
335
|
const hadReference = await deleteReference(context.executor(), id);
|
|
314
336
|
|
|
@@ -66,6 +66,12 @@ export type ReconciliationReport = {
|
|
|
66
66
|
export type DeletionOutcome = {
|
|
67
67
|
scopeKey?: string;
|
|
68
68
|
removedRecords: number;
|
|
69
|
+
/**
|
|
70
|
+
* True when the scope was deleted but its records could not be counted — an
|
|
71
|
+
* encrypted scope whose key was unavailable, or a file that would not open.
|
|
72
|
+
* `removedRecords` is then 0 and means "unknown". Added in 0.2.0.
|
|
73
|
+
*/
|
|
74
|
+
recordCountUnavailable?: boolean;
|
|
69
75
|
removedArtifacts: string[];
|
|
70
76
|
orphanedReferences: string[];
|
|
71
77
|
failedRemovals: string[];
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import type { Transaction } from './transaction';
|
|
9
9
|
|
|
10
10
|
/** Low-level escape hatch for schema work a migration needs. */
|
|
11
|
-
|
|
11
|
+
// Attachments are not part of a migration: a migration reshapes records, and
|
|
12
|
+
// files written inside one would survive its rollback with nothing to own them.
|
|
13
|
+
export type MigrationContext = Omit<Transaction, 'attachments'> & {
|
|
12
14
|
/** Execute a raw statement against the scope's database. */
|
|
13
15
|
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
14
16
|
};
|
package/src/contract/record.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* research.md R6.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { DeletionOutcome } from './attachment';
|
|
11
|
+
|
|
10
12
|
/** Anything the package will store and hand back unchanged. */
|
|
11
13
|
export type StorableValue =
|
|
12
14
|
| string
|
|
@@ -39,4 +41,11 @@ export type WriteOperation = 'created' | 'updated' | 'deleted' | 'restored';
|
|
|
39
41
|
export type WriteOutcome = {
|
|
40
42
|
operation: WriteOperation;
|
|
41
43
|
id: string;
|
|
44
|
+
/**
|
|
45
|
+
* Present on a delete when `cascadeAttachments` is on: which attachment files
|
|
46
|
+
* went with the record and which could not be removed (FR-071). Inside a
|
|
47
|
+
* transaction the files are removed once it commits, so `removedArtifacts`
|
|
48
|
+
* lists what was scheduled. Added in 0.2.0.
|
|
49
|
+
*/
|
|
50
|
+
cascade?: DeletionOutcome;
|
|
42
51
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { FilterNode, Query, ResultPage } from './query';
|
|
10
10
|
import type { RecordInput, StorableRecordData, StoredRecord, WriteOutcome } from './record';
|
|
11
|
+
import type { Attachments } from './adapter';
|
|
11
12
|
|
|
12
13
|
/** Collection operations, available on a handle and inside a transaction. */
|
|
13
14
|
export type CollectionOps<T extends StorableRecordData = StorableRecordData> = {
|
|
@@ -29,6 +30,13 @@ export type Transaction = {
|
|
|
29
30
|
collection<T extends StorableRecordData = StorableRecordData>(
|
|
30
31
|
name: string,
|
|
31
32
|
): CollectionOps<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Attachments whose references join this transaction (FR-072): they roll
|
|
35
|
+
* back with it, and cascaded files are removed only once it commits. Added
|
|
36
|
+
* in 0.2.0 — the handle's own `attachments()` no longer reaches into an
|
|
37
|
+
* in-flight transaction.
|
|
38
|
+
*/
|
|
39
|
+
attachments(): Attachments;
|
|
32
40
|
/** Discards every write in this transaction (FR-041). */
|
|
33
41
|
abort(): Promise<void>;
|
|
34
42
|
};
|
|
@@ -31,6 +31,7 @@ import { DiagnosticCodes } from '../contract/errors';
|
|
|
31
31
|
import { raise } from '../diagnostics/errors';
|
|
32
32
|
import { nonceForChunk } from './derive';
|
|
33
33
|
import { cipherModule } from './provider';
|
|
34
|
+
import { asciiAt, concatBytes } from '../platform/bytes';
|
|
34
35
|
|
|
35
36
|
export const MAGIC = 'LDB1';
|
|
36
37
|
export const FORMAT_VERSION = 1;
|
|
@@ -49,30 +50,35 @@ export function chunkCountFor(plaintextSize: number, chunkSize: number): number
|
|
|
49
50
|
return plaintextSize === 0 ? 0 : Math.ceil(plaintextSize / chunkSize);
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export function encodeHeader(header: ArtifactHeader):
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
export function encodeHeader(header: ArtifactHeader): Uint8Array {
|
|
54
|
+
const bytes = new Uint8Array(HEADER_BYTES);
|
|
55
|
+
for (let index = 0; index < 4; index += 1) {
|
|
56
|
+
bytes[index] = MAGIC.charCodeAt(index);
|
|
57
|
+
}
|
|
58
|
+
// Big-endian, as DataView writes by default; the on-disk format is unchanged.
|
|
59
|
+
const view = new DataView(bytes.buffer);
|
|
60
|
+
view.setUint16(4, header.formatVersion);
|
|
61
|
+
view.setUint32(6, header.chunkSize);
|
|
62
|
+
view.setUint32(10, header.chunkCount);
|
|
58
63
|
// Split across two 32-bit writes: artifact sizes stay well inside the safe
|
|
59
64
|
// integer range, and this avoids depending on BigInt in Hermes.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return
|
|
65
|
+
view.setUint32(14, Math.floor(header.plaintextSize / 0x100000000));
|
|
66
|
+
view.setUint32(18, header.plaintextSize % 0x100000000);
|
|
67
|
+
return bytes;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
export function decodeHeader(
|
|
66
|
-
if (
|
|
70
|
+
export function decodeHeader(bytes: Uint8Array): ArtifactHeader {
|
|
71
|
+
if (bytes.length < HEADER_BYTES || asciiAt(bytes, 0, 4) !== MAGIC) {
|
|
67
72
|
raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
|
|
68
73
|
message: 'artifact header is not a recognised encrypted artifact',
|
|
69
74
|
});
|
|
70
75
|
}
|
|
76
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, HEADER_BYTES);
|
|
71
77
|
return {
|
|
72
|
-
formatVersion:
|
|
73
|
-
chunkSize:
|
|
74
|
-
chunkCount:
|
|
75
|
-
plaintextSize:
|
|
78
|
+
formatVersion: view.getUint16(4),
|
|
79
|
+
chunkSize: view.getUint32(6),
|
|
80
|
+
chunkCount: view.getUint32(10),
|
|
81
|
+
plaintextSize: view.getUint32(14) * 0x100000000 + view.getUint32(18),
|
|
76
82
|
};
|
|
77
83
|
}
|
|
78
84
|
|
|
@@ -102,16 +108,16 @@ export function encryptedLength(header: ArtifactHeader): number {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/** One chunk in, ciphertext with its tag appended out. */
|
|
105
|
-
export function encryptChunk(key: Uint8Array, index: number, plaintext:
|
|
111
|
+
export function encryptChunk(key: Uint8Array, index: number, plaintext: Uint8Array): Uint8Array {
|
|
106
112
|
const cipher = cipherModule().createCipheriv(ALGORITHM, key, nonceForChunk(index));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
return concatBytes([
|
|
114
|
+
new Uint8Array(cipher.update(plaintext)),
|
|
115
|
+
new Uint8Array(cipher.final()),
|
|
116
|
+
new Uint8Array(cipher.getAuthTag()),
|
|
110
117
|
]);
|
|
111
|
-
return Buffer.concat([body, Buffer.from(cipher.getAuthTag())]);
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
export function decryptChunk(key: Uint8Array, index: number, stored:
|
|
120
|
+
export function decryptChunk(key: Uint8Array, index: number, stored: Uint8Array): Uint8Array {
|
|
115
121
|
if (stored.length < TAG_BYTES) {
|
|
116
122
|
raise(DiagnosticCodes.ATTACHMENT_UNDECRYPTABLE, {
|
|
117
123
|
message: 'artifact chunk is truncated',
|
|
@@ -123,7 +129,7 @@ export function decryptChunk(key: Uint8Array, index: number, stored: Buffer): Bu
|
|
|
123
129
|
const decipher = cipherModule().createDecipheriv(ALGORITHM, key, nonceForChunk(index));
|
|
124
130
|
decipher.setAuthTag(tag);
|
|
125
131
|
try {
|
|
126
|
-
return
|
|
132
|
+
return concatBytes([new Uint8Array(decipher.update(body)), new Uint8Array(decipher.final())]);
|
|
127
133
|
} catch (error) {
|
|
128
134
|
// A failing tag is the only signal that separates "exists but will not
|
|
129
135
|
// decrypt" from "missing", and an app responds to them differently.
|
package/src/crypto/derive.ts
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { cipherModule } from './provider';
|
|
17
|
+
import { utf8Bytes } from '../platform/bytes';
|
|
17
18
|
|
|
18
19
|
const HKDF_DIGEST = 'sha256';
|
|
19
20
|
const KEY_BYTES = 32;
|
|
20
21
|
const NONCE_BYTES = 12;
|
|
21
22
|
|
|
22
23
|
function bytesOf(value: string): Uint8Array {
|
|
23
|
-
return
|
|
24
|
+
return utf8Bytes(value);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** 32 bytes, one artifact, one database key. */
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte handling without Node's `Buffer`.
|
|
3
|
+
*
|
|
4
|
+
* Hermes has no global `Buffer`, and `react-native-quick-crypto` installs one
|
|
5
|
+
* only when the app calls its `install()` — which nothing in this package or its
|
|
6
|
+
* documentation asked for. Until 0.2.0 attachment writes and reads used the
|
|
7
|
+
* global anyway, so on a device every attachment operation threw. Everything
|
|
8
|
+
* here is plain `Uint8Array` and a self-contained base64 codec, identical on
|
|
9
|
+
* Hermes and in Node.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
13
|
+
const LOOKUP = (() => {
|
|
14
|
+
const table = new Int16Array(128).fill(-1);
|
|
15
|
+
for (let index = 0; index < ALPHABET.length; index += 1) {
|
|
16
|
+
table[ALPHABET.charCodeAt(index)] = index;
|
|
17
|
+
}
|
|
18
|
+
// The URL-safe variants decode too, since some file APIs return them.
|
|
19
|
+
table['-'.charCodeAt(0)] = 62;
|
|
20
|
+
table['_'.charCodeAt(0)] = 63;
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
/* eslint-disable no-bitwise -- base64 is bit packing; there is no clearer way to write it. */
|
|
25
|
+
|
|
26
|
+
/** Characters per `String.fromCharCode` call; large spreads overflow the stack. */
|
|
27
|
+
const CHUNK = 0x8000;
|
|
28
|
+
|
|
29
|
+
export function base64ToBytes(base64: string): Uint8Array {
|
|
30
|
+
if (typeof globalThis.atob === 'function') {
|
|
31
|
+
let binary: string | undefined;
|
|
32
|
+
try {
|
|
33
|
+
binary = globalThis.atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
|
34
|
+
} catch {
|
|
35
|
+
// atob is strict where Buffer was lenient; fall through to the
|
|
36
|
+
// tolerant decoder rather than turn stray characters into a crash.
|
|
37
|
+
}
|
|
38
|
+
if (binary !== undefined) {
|
|
39
|
+
const bytes = new Uint8Array(binary.length);
|
|
40
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
41
|
+
bytes[index] = binary.charCodeAt(index);
|
|
42
|
+
}
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Decode directly, skipping padding and anything outside the alphabet.
|
|
47
|
+
const bytes = new Uint8Array(Math.ceil((base64.length * 3) / 4));
|
|
48
|
+
let out = 0;
|
|
49
|
+
let buffer = 0;
|
|
50
|
+
let bits = 0;
|
|
51
|
+
for (let index = 0; index < base64.length; index += 1) {
|
|
52
|
+
const code = base64.charCodeAt(index);
|
|
53
|
+
const value = code < 128 ? LOOKUP[code]! : -1;
|
|
54
|
+
if (value < 0) continue;
|
|
55
|
+
buffer = (buffer << 6) | value;
|
|
56
|
+
bits += 6;
|
|
57
|
+
if (bits >= 8) {
|
|
58
|
+
bits -= 8;
|
|
59
|
+
bytes[out++] = (buffer >> bits) & 0xff;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return bytes.subarray(0, out);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
66
|
+
if (typeof globalThis.btoa === 'function') {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
for (let index = 0; index < bytes.length; index += CHUNK) {
|
|
69
|
+
parts.push(String.fromCharCode(...bytes.subarray(index, index + CHUNK)));
|
|
70
|
+
}
|
|
71
|
+
return globalThis.btoa(parts.join(''));
|
|
72
|
+
}
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
for (let start = 0; start < bytes.length; start += CHUNK * 3) {
|
|
75
|
+
let piece = '';
|
|
76
|
+
const end = Math.min(bytes.length, start + CHUNK * 3);
|
|
77
|
+
let index = start;
|
|
78
|
+
for (; index + 2 < end; index += 3) {
|
|
79
|
+
const triple = (bytes[index]! << 16) | (bytes[index + 1]! << 8) | bytes[index + 2]!;
|
|
80
|
+
piece +=
|
|
81
|
+
ALPHABET[triple >> 18]! +
|
|
82
|
+
ALPHABET[(triple >> 12) & 63]! +
|
|
83
|
+
ALPHABET[(triple >> 6) & 63]! +
|
|
84
|
+
ALPHABET[triple & 63]!;
|
|
85
|
+
}
|
|
86
|
+
const remaining = end - index;
|
|
87
|
+
if (remaining === 1) {
|
|
88
|
+
const value = bytes[index]!;
|
|
89
|
+
piece += `${ALPHABET[value >> 2]}${ALPHABET[(value & 3) << 4]}==`;
|
|
90
|
+
} else if (remaining === 2) {
|
|
91
|
+
const value = (bytes[index]! << 8) | bytes[index + 1]!;
|
|
92
|
+
piece += `${ALPHABET[value >> 10]}${ALPHABET[(value >> 4) & 63]}${ALPHABET[(value & 15) << 2]}=`;
|
|
93
|
+
}
|
|
94
|
+
parts.push(piece);
|
|
95
|
+
}
|
|
96
|
+
return parts.join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** UTF-8 encoding. `TextEncoder` is in Hermes and Node; the loop is the fallback. */
|
|
100
|
+
export function utf8Bytes(value: string): Uint8Array {
|
|
101
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
102
|
+
return new TextEncoder().encode(value);
|
|
103
|
+
}
|
|
104
|
+
const encoded = unescape(encodeURIComponent(value));
|
|
105
|
+
const bytes = new Uint8Array(encoded.length);
|
|
106
|
+
for (let index = 0; index < encoded.length; index += 1) {
|
|
107
|
+
bytes[index] = encoded.charCodeAt(index);
|
|
108
|
+
}
|
|
109
|
+
return bytes;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** UTF-8 decoding, the inverse of `utf8Bytes`. */
|
|
113
|
+
export function utf8String(bytes: Uint8Array): string {
|
|
114
|
+
if (typeof TextDecoder !== 'undefined') {
|
|
115
|
+
return new TextDecoder().decode(bytes);
|
|
116
|
+
}
|
|
117
|
+
let binary = '';
|
|
118
|
+
for (const byte of bytes) {
|
|
119
|
+
binary += String.fromCharCode(byte);
|
|
120
|
+
}
|
|
121
|
+
return decodeURIComponent(escape(binary));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* eslint-enable no-bitwise */
|
|
125
|
+
|
|
126
|
+
export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
127
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
128
|
+
const joined = new Uint8Array(total);
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
joined.set(part, offset);
|
|
132
|
+
offset += part.length;
|
|
133
|
+
}
|
|
134
|
+
return joined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ASCII text of `length` bytes from `start`, for magic numbers. */
|
|
138
|
+
export function asciiAt(bytes: Uint8Array, start: number, length: number): string {
|
|
139
|
+
let text = '';
|
|
140
|
+
for (let index = start; index < start + length && index < bytes.length; index += 1) {
|
|
141
|
+
text += String.fromCharCode(bytes[index]!);
|
|
142
|
+
}
|
|
143
|
+
return text;
|
|
144
|
+
}
|
package/src/query/budgets.ts
CHANGED
|
@@ -10,15 +10,12 @@ import { Budgets } from '../contract/config';
|
|
|
10
10
|
import { DiagnosticCodes } from '../contract/errors';
|
|
11
11
|
import type { StorableRecordData } from '../contract/record';
|
|
12
12
|
import { raise } from '../diagnostics/errors';
|
|
13
|
+
import { utf8Bytes } from '../platform/bytes';
|
|
13
14
|
|
|
14
15
|
/** UTF-8 byte length of the serialized payload — what actually hits disk. */
|
|
15
16
|
export function serializedSize(data: StorableRecordData): number {
|
|
16
17
|
const json = JSON.stringify(data ?? {});
|
|
17
|
-
|
|
18
|
-
if (typeof TextEncoder !== 'undefined') {
|
|
19
|
-
return new TextEncoder().encode(json).length;
|
|
20
|
-
}
|
|
21
|
-
return Buffer.byteLength(json, 'utf8');
|
|
18
|
+
return utf8Bytes(json).length;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
|
package/src/query/cursor.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { StoredRecord } from '../contract/record';
|
|
|
14
14
|
import { raise } from '../diagnostics/errors';
|
|
15
15
|
import { compareValues } from './compare';
|
|
16
16
|
import { sortValueOf } from './sort';
|
|
17
|
+
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../platform/bytes';
|
|
17
18
|
|
|
18
19
|
export type CursorPayload = {
|
|
19
20
|
/** Sort key fields and directions, so a cursor cannot be reused across shapes. */
|
|
@@ -30,14 +31,14 @@ function toBase64(value: string): string {
|
|
|
30
31
|
if (typeof globalThis.btoa === 'function') {
|
|
31
32
|
return globalThis.btoa(unescape(encodeURIComponent(value)));
|
|
32
33
|
}
|
|
33
|
-
return
|
|
34
|
+
return bytesToBase64(utf8Bytes(value));
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function fromBase64(value: string): string {
|
|
37
38
|
if (typeof globalThis.atob === 'function') {
|
|
38
39
|
return decodeURIComponent(escape(globalThis.atob(value)));
|
|
39
40
|
}
|
|
40
|
-
return
|
|
41
|
+
return utf8String(base64ToBytes(value));
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function encodeCursor(record: StoredRecord, sort: SortKey[]): string {
|