@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.
@@ -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
- // 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);
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
- /** Everything belonging to one record, for the FR-070 cascade. */
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
 
@@ -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: Buffer,
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).toString('base64'), 'base64');
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).toString('base64'),
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 = Buffer.from(input.content, 'base64');
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 = Buffer.from(
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: Buffer[] = [];
256
+ const pieces: Uint8Array[] = [];
259
257
 
260
258
  for (let index = first; index <= last; index += 1) {
261
- const stored = Buffer.from(
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 Buffer.concat(pieces).toString('base64');
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
- export type MigrationContext = Transaction & {
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
  };
@@ -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): 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);
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
- buffer.writeUInt32BE(Math.floor(header.plaintextSize / 0x100000000), 14);
61
- buffer.writeUInt32BE(header.plaintextSize % 0x100000000, 18);
62
- return buffer;
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(buffer: Buffer): ArtifactHeader {
66
- if (buffer.length < HEADER_BYTES || buffer.toString('ascii', 0, 4) !== MAGIC) {
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: buffer.readUInt16BE(4),
73
- chunkSize: buffer.readUInt32BE(6),
74
- chunkCount: buffer.readUInt32BE(10),
75
- plaintextSize: buffer.readUInt32BE(14) * 0x100000000 + buffer.readUInt32BE(18),
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: Buffer): Buffer {
111
+ export function encryptChunk(key: Uint8Array, index: number, plaintext: Uint8Array): Uint8Array {
106
112
  const cipher = cipherModule().createCipheriv(ALGORITHM, key, nonceForChunk(index));
107
- const body = Buffer.concat([
108
- Buffer.from(cipher.update(plaintext)),
109
- Buffer.from(cipher.final()),
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: Buffer): Buffer {
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 Buffer.concat([Buffer.from(decipher.update(body)), Buffer.from(decipher.final())]);
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.
@@ -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 Uint8Array.from(Buffer.from(value, 'utf8'));
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
+ }
@@ -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
- // TextEncoder is present in Hermes and in Node; Buffer is the fallback.
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 {
@@ -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 Buffer.from(value, 'utf8').toString('base64');
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 Buffer.from(value, 'base64').toString('utf8');
41
+ return utf8String(base64ToBytes(value));
41
42
  }
42
43
 
43
44
  export function encodeCursor(record: StoredRecord, sort: SortKey[]): string {