@chipmobilesdk/rn-local-db 0.3.0 → 0.4.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Bringing the physical tables in line with the declared schema, and skipping
3
+ * that work when nothing changed (LDB-07, LDB-11).
4
+ *
5
+ * Until 0.4.0 every open ran the whole set — CREATE TABLE, PRAGMA table_info,
6
+ * CREATE INDEX per index, for every collection — some sixty native round-trips
7
+ * for ten collections, on the startup path. The applied schema is now recorded
8
+ * in `_meta`; an open whose declaration matches it runs no DDL at all.
9
+ */
10
+ import type { DatabaseConfig } from '../contract/config';
11
+ import type { CollectionSchema } from '../contract/schema';
12
+ import { ensureAttachmentTable } from '../artifacts/store';
13
+ import type { Connection } from '../sqlite/connection';
14
+ import {
15
+ addColumnSql,
16
+ createIndexSql,
17
+ createTableSql,
18
+ physicalIndexName,
19
+ } from '../sqlite/row-codec';
20
+ import { text } from '../sqlite/values';
21
+
22
+ /** Bumped whenever the DDL this package generates changes, so it is re-applied once. */
23
+ const DDL_VERSION = 1;
24
+ const FINGERPRINT_KEY = 'schema_fingerprint';
25
+
26
+ /** What was declared, and by which generation of this package's DDL. */
27
+ function fingerprintOf(config: DatabaseConfig): string {
28
+ return JSON.stringify([DDL_VERSION, config.collections]);
29
+ }
30
+
31
+ async function appliedFingerprint(connection: Connection): Promise<string | null> {
32
+ try {
33
+ const result = await connection.execute('SELECT value FROM _meta WHERE key = ?', [
34
+ FINGERPRINT_KEY,
35
+ ]);
36
+ const row = result.rows[0];
37
+ return row ? text(row.value) : null;
38
+ } catch {
39
+ // A database that has never been opened has no `_meta` yet.
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** `unique` and the column list of an index, read back from the statement SQLite stored. */
45
+ function definitionOf(sql: string): string {
46
+ const unique = /^\s*CREATE\s+UNIQUE\s/i.test(sql);
47
+ const columns = sql.slice(sql.lastIndexOf('(') + 1, sql.lastIndexOf(')')).replace(/["\s]/g, '');
48
+ return `${unique ? 'unique' : 'plain'}:${columns}`;
49
+ }
50
+
51
+ /**
52
+ * Indexes follow their declaration (LDB-11). `CREATE INDEX IF NOT EXISTS` keyed
53
+ * on the name alone meant that adding `unique: true` to an existing index, or
54
+ * changing its fields, did nothing, and an index no longer declared stayed
55
+ * forever. An index whose definition changed is dropped and recreated, and one
56
+ * of this package's that is no longer declared is dropped. Only indexes carrying
57
+ * the collection's own prefix are touched.
58
+ */
59
+ async function syncIndexes(connection: Connection, schema: CollectionSchema): Promise<void> {
60
+ const table = `c_${schema.name}`;
61
+ const existing = await connection.execute(
62
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL",
63
+ [table],
64
+ );
65
+ const current = new Map(existing.rows.map(row => [text(row.name), text(row.sql)]));
66
+ const wanted = new Map(
67
+ createIndexSql(schema).map(statement => {
68
+ const name = /INDEX IF NOT EXISTS "([^"]+)"/.exec(statement)?.[1] ?? '';
69
+ return [name, statement] as const;
70
+ }),
71
+ );
72
+
73
+ for (const index of schema.indexes ?? []) {
74
+ // Indexes created before 0.2.0 carry the bare declared name; they are
75
+ // recreated under the collection-scoped one. No row data moves.
76
+ if (current.has(index.name) && index.name !== physicalIndexName(schema, index.name)) {
77
+ await connection.execute(`DROP INDEX IF EXISTS "${index.name}"`);
78
+ }
79
+ }
80
+ for (const [name, sql] of current) {
81
+ const statement = wanted.get(name);
82
+ const ours = name.startsWith(`${table}__`);
83
+ if (
84
+ (statement === undefined && ours) ||
85
+ (statement && definitionOf(statement) !== definitionOf(sql))
86
+ ) {
87
+ await connection.execute(`DROP INDEX IF EXISTS "${name}"`);
88
+ }
89
+ }
90
+ for (const statement of wanted.values()) {
91
+ await connection.execute(statement);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Adding a column rather than recreating the table is what makes declaring a
97
+ * new queryable field non-destructive: the payload already holds the value, so
98
+ * the new column is backfilled from it and no record is rewritten by hand
99
+ * (research.md R6).
100
+ */
101
+ async function ensureCollection(connection: Connection, schema: CollectionSchema): Promise<void> {
102
+ await connection.execute(createTableSql(schema));
103
+
104
+ const info = await connection.execute(`PRAGMA table_info("c_${schema.name}")`);
105
+ const present = new Set(info.rows.map(row => text(row.name)));
106
+
107
+ for (const field of schema.fields ?? []) {
108
+ const column = `f_${field.name}`;
109
+ if (present.has(column)) {
110
+ continue;
111
+ }
112
+ await connection.execute(addColumnSql(schema, field));
113
+ // The same projection a write applies (`projectValue`): an object or an
114
+ // array projects to NULL. A bare json_extract backfilled those as JSON
115
+ // text, so a filter matched old records and new ones differently (LDB-11).
116
+ const path = `'$."${field.name}"'`;
117
+ await connection.execute(
118
+ `UPDATE "c_${schema.name}" SET "${column}" = CASE WHEN json_type(payload, ${path}) IN ('object', 'array') THEN NULL ELSE json_extract(payload, ${path}) END`,
119
+ );
120
+ }
121
+
122
+ await syncIndexes(connection, schema);
123
+ }
124
+
125
+ export interface SchemaSync {
126
+ /** True when the stored schema already matched, so `_meta` exists and no DDL ran. */
127
+ readonly unchanged: boolean;
128
+ /** Records the declaration as applied. Call once the open has succeeded. */
129
+ record(): Promise<void>;
130
+ }
131
+
132
+ export async function syncSchema(
133
+ connection: Connection,
134
+ config: DatabaseConfig,
135
+ ): Promise<SchemaSync> {
136
+ const fingerprint = fingerprintOf(config);
137
+ if ((await appliedFingerprint(connection)) === fingerprint) {
138
+ return { unchanged: true, record: async () => undefined };
139
+ }
140
+
141
+ for (const schema of config.collections) {
142
+ await ensureCollection(connection, schema);
143
+ }
144
+ await ensureAttachmentTable(connection);
145
+
146
+ return {
147
+ unchanged: false,
148
+ async record() {
149
+ await connection.execute(
150
+ 'INSERT INTO _meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
151
+ [FINGERPRINT_KEY, fingerprint],
152
+ );
153
+ },
154
+ };
155
+ }
@@ -19,7 +19,6 @@ import type { Executor } from '../sqlite/connection';
19
19
  import { artifactPath } from './paths';
20
20
  import {
21
21
  deleteReference,
22
- listReferences,
23
22
  listReferencesFor,
24
23
  type ArtifactContext,
25
24
  type DeferredUnlinks,
@@ -99,16 +98,3 @@ export async function cascadeForOwner(
99
98
  deferred,
100
99
  );
101
100
  }
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
- }
@@ -152,6 +152,12 @@ async function writePlaintext(path: string, content: string): Promise<void> {
152
152
  await fileSystem().writeFile(path, content, 'base64');
153
153
  }
154
154
 
155
+ /** Where an artifact's bytes come from: how many, and any one chunk of them. */
156
+ type ByteSource = {
157
+ size: number;
158
+ chunk(offset: number, length: number): Promise<Uint8Array>;
159
+ };
160
+
155
161
  /**
156
162
  * Encrypted write, one chunk in flight.
157
163
  *
@@ -159,43 +165,87 @@ async function writePlaintext(path: string, content: string): Promise<void> {
159
165
  * plaintext length — are all known from the input length before any
160
166
  * encryption happens. Only the tags depend on the ciphertext, and they follow
161
167
  * their own chunk, so the whole write is append-only and peak memory is one
162
- * chunk regardless of artifact size (FR-107a).
168
+ * chunk regardless of artifact size when the source is a file (FR-107a).
163
169
  */
164
170
  async function writeEncrypted(
165
171
  path: string,
166
172
  key: Uint8Array,
167
- plaintext: Uint8Array,
173
+ source: ByteSource,
168
174
  ): Promise<{ chunkSize: number; chunkCount: number }> {
169
- const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
175
+ const header = headerFor(source.size, Budgets.ARTIFACT_CHUNK_BYTES);
170
176
  await fileSystem().writeFile(path, bytesToBase64(encodeHeader(header)), 'base64');
171
177
 
172
178
  for (let index = 0; index < header.chunkCount; index += 1) {
173
- const start = index * header.chunkSize;
174
- const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
179
+ const chunk = await source.chunk(index * header.chunkSize, chunkPlaintextLength(header, index));
175
180
  await fileSystem().appendFile(path, bytesToBase64(encryptChunk(key, index, chunk)), 'base64');
176
181
  }
177
182
 
178
183
  return { chunkSize: header.chunkSize, chunkCount: header.chunkCount };
179
184
  }
180
185
 
186
+ function sourceFileOf(input: AttachmentInput): string | null {
187
+ if (typeof input.sourcePath !== 'string') {
188
+ return null;
189
+ }
190
+ return input.sourcePath.startsWith('file://')
191
+ ? input.sourcePath.slice('file://'.length)
192
+ : input.sourcePath;
193
+ }
194
+
195
+ /**
196
+ * The input as something to read from. A base64 `content` is decoded whole,
197
+ * which is the cost of that form; a `sourcePath` is only measured here and
198
+ * read a chunk at a time, or not read by JS at all (LDB-09).
199
+ */
200
+ async function byteSourceFor(input: AttachmentInput): Promise<ByteSource> {
201
+ const file = sourceFileOf(input);
202
+ if (file === null) {
203
+ if (typeof input.content !== 'string') {
204
+ raise(DiagnosticCodes.CONFIG_INVALID, {
205
+ message: 'an attachment needs content or sourcePath',
206
+ detail: { attachmentId: input.id },
207
+ });
208
+ }
209
+ const plaintext = base64ToBytes(input.content);
210
+ return {
211
+ size: plaintext.length,
212
+ chunk: async (offset, length) => plaintext.subarray(offset, offset + length),
213
+ };
214
+ }
215
+ let size: number;
216
+ try {
217
+ size = (await fileSystem().stat(file)).size;
218
+ } catch (error) {
219
+ raiseNative(error, { operation: 'attachment' });
220
+ }
221
+ return {
222
+ size,
223
+ chunk: async (offset, length) =>
224
+ base64ToBytes(await fileSystem().read(file, length, offset, 'base64')),
225
+ };
226
+ }
227
+
181
228
  export async function writeArtifact(
182
229
  context: ArtifactContext,
183
230
  input: AttachmentInput,
184
231
  ): Promise<AttachmentReference> {
185
232
  throwIfFaulted('attachmentWrite');
186
- const plaintext = base64ToBytes(input.content);
187
- assertArtifactWithinBudget(input.id, plaintext.length);
233
+ const source = await byteSourceFor(input);
234
+ assertArtifactWithinBudget(input.id, source.size);
188
235
 
189
236
  const path = artifactPath(context.layout, context.scopeKey, input.id);
190
237
  const key = context.artifactKey(input.id);
238
+ const file = sourceFileOf(input);
191
239
 
192
240
  let chunking: { chunkSize: number; chunkCount: number } | undefined;
193
241
  try {
194
242
  await fileSystem().mkdir(artifactsDirectory(context.layout, context.scopeKey));
195
243
  if (key) {
196
- chunking = await writeEncrypted(path, key, plaintext);
244
+ chunking = await writeEncrypted(path, key, source);
245
+ } else if (file !== null) {
246
+ await fileSystem().copyFile(file, path);
197
247
  } else {
198
- await writePlaintext(path, input.content);
248
+ await writePlaintext(path, input.content as string);
199
249
  }
200
250
  } catch (error) {
201
251
  // A partial file with no reference is an orphan reconciliation can
@@ -207,7 +257,7 @@ export async function writeArtifact(
207
257
  const reference: AttachmentReference = {
208
258
  id: input.id,
209
259
  contentType: input.contentType,
210
- size: plaintext.length,
260
+ size: source.size,
211
261
  createdAt: context.now(),
212
262
  };
213
263
  if (input.owner) {
@@ -11,15 +11,36 @@ export type AttachmentOwner = {
11
11
  id: string;
12
12
  };
13
13
 
14
- export type AttachmentInput = {
14
+ type AttachmentDescription = {
15
15
  id: string;
16
16
  /** Absent means the attachment belongs to the scope rather than a record. */
17
17
  owner?: AttachmentOwner | undefined;
18
18
  contentType: string;
19
- /** Base64 content. Written incrementally; never held whole when encrypted. */
20
- content: string;
21
19
  };
22
20
 
21
+ /**
22
+ * Where the bytes come from. Exactly one of the two.
23
+ *
24
+ * `sourcePath` is the one to use for anything large — a photo, a recording.
25
+ * The file is copied (or, when encrypted, read and encrypted 256 KB at a
26
+ * time), so memory does not grow with its size. `content` holds the whole
27
+ * payload in JS as base64 and is decoded in one piece: fine for a thumbnail,
28
+ * several times the file's size in memory for a video.
29
+ */
30
+ export type AttachmentInput = AttachmentDescription &
31
+ (
32
+ | {
33
+ /** The complete content, base64. Held and decoded whole. */
34
+ content: string;
35
+ sourcePath?: undefined;
36
+ }
37
+ | {
38
+ /** Absolute path of a file the app can read. A `file://` prefix is accepted. */
39
+ sourcePath: string;
40
+ content?: undefined;
41
+ }
42
+ );
43
+
23
44
  export type AttachmentReference = {
24
45
  id: string;
25
46
  owner?: AttachmentOwner | undefined;
@@ -10,9 +10,12 @@ import type { Transaction } from './transaction';
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
12
  // files written inside one would survive its rollback with nothing to own them.
13
+ /** A value SQLite can bind. */
14
+ export type SqlParameter = string | number | boolean | null;
15
+
13
16
  export type MigrationContext = Omit<Transaction, 'attachments'> & {
14
17
  /** Execute a raw statement against the scope's database. */
15
- execute(sql: string, params?: unknown[]): Promise<void>;
18
+ execute(sql: string, params?: SqlParameter[]): Promise<void>;
16
19
  };
17
20
 
18
21
  export type Migration = {
@@ -73,7 +73,7 @@ export function isLogging(): boolean {
73
73
  * installed and the build is a development build, so a production bundle pays
74
74
  * nothing and emits nothing (FR-081).
75
75
  */
76
- export function instrument<T extends Record<string, (...args: never[]) => unknown>>(
76
+ export function instrument<T extends object>(
77
77
  collection: string,
78
78
  adapter: 'sqlite' | 'memory',
79
79
  operations: T,
@@ -85,6 +85,10 @@ export function instrument<T extends Record<string, (...args: never[]) => unknow
85
85
 
86
86
  const wrapped: Record<string, unknown> = {};
87
87
  for (const [name, operation] of Object.entries(operations)) {
88
+ if (typeof operation !== 'function') {
89
+ wrapped[name] = operation;
90
+ continue;
91
+ }
88
92
  wrapped[name] = async (...args: never[]): Promise<unknown> => {
89
93
  const started = Date.now();
90
94
  try {
@@ -21,7 +21,7 @@
21
21
 
22
22
  import type { DatabaseConfig } from '../contract/config';
23
23
  import { DiagnosticCodes } from '../contract/errors';
24
- import type { MigrationContext } from '../contract/migration';
24
+ import type { MigrationContext, SqlParameter } from '../contract/migration';
25
25
  import type { StorableRecordData } from '../contract/record';
26
26
  import type { CollectionSchema } from '../contract/schema';
27
27
  import type { CollectionOps } from '../contract/transaction';
@@ -29,7 +29,13 @@ import { raise } from '../diagnostics/errors';
29
29
  import { throwIfFaulted } from '../diagnostics/faults';
30
30
  import { createSqlCollection } from '../adapter/collection';
31
31
  import type { Connection, Executor } from '../sqlite/connection';
32
- import { ensureLedger, readSchemaVersion, recordAttempt, writeSchemaVersion } from './ledger';
32
+ import {
33
+ ensureLedger,
34
+ FORMAT_VERSION,
35
+ readVersions,
36
+ recordAttempt,
37
+ writeSchemaVersion,
38
+ } from './ledger';
33
39
  import { pendingMigrations, validateMigrationChain } from './validate-chain';
34
40
 
35
41
  export type MigrationRunContext = {
@@ -44,6 +50,8 @@ export type MigrationRunContext = {
44
50
  * still be holding something usable (FR-050).
45
51
  */
46
52
  closeOnFailure(): Promise<void>;
53
+ /** The bookkeeping tables are known to exist, so creating them is skipped (LDB-07). */
54
+ ledgerReady?: boolean;
47
55
  };
48
56
 
49
57
  function migrationContextFor(context: MigrationRunContext, executor: Executor): MigrationContext {
@@ -66,8 +74,8 @@ function migrationContextFor(context: MigrationRunContext, executor: Executor):
66
74
  message: 'a migration cannot abort its own transaction',
67
75
  });
68
76
  },
69
- async execute(sql: string, params?: unknown[]): Promise<void> {
70
- await executor.execute(sql, params as never);
77
+ async execute(sql: string, params?: SqlParameter[]): Promise<void> {
78
+ await executor.execute(sql, params);
71
79
  },
72
80
  };
73
81
  }
@@ -75,9 +83,22 @@ function migrationContextFor(context: MigrationRunContext, executor: Executor):
75
83
  export async function runMigrations(context: MigrationRunContext): Promise<number> {
76
84
  const { config, connection } = context;
77
85
  validateMigrationChain(config);
78
- await ensureLedger(connection);
86
+ // Skipped when the open already found its bookkeeping tables in place.
87
+ if (context.ledgerReady !== true) {
88
+ await ensureLedger(connection);
89
+ }
90
+
91
+ const { schema: recorded, format } = await readVersions(connection);
79
92
 
80
- const recorded = await readSchemaVersion(connection);
93
+ if (format > FORMAT_VERSION) {
94
+ // A file laid out by a newer release of this package. Reading it with
95
+ // older code is the same hazard as a schema downgrade, one layer down.
96
+ await context.closeOnFailure();
97
+ raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
98
+ message: 'database was written by a newer version of this package',
99
+ detail: { recordedVersion: format, version: FORMAT_VERSION },
100
+ });
101
+ }
81
102
 
82
103
  if (recorded > config.schemaVersion) {
83
104
  // Opening anyway would mean newer data read by older code, which drops
@@ -12,9 +12,9 @@
12
12
  * with a long half-life, sitting in a file the next crash report might carry.
13
13
  */
14
14
 
15
- import type { MigrationLedgerEntry, MigrationOutcome } from '../contract/migration';
15
+ import type { MigrationOutcome } from '../contract/migration';
16
16
  import type { Executor } from '../sqlite/connection';
17
- import { maybeText, num, text } from '../sqlite/values';
17
+ import { text } from '../sqlite/values';
18
18
 
19
19
  /** Physical layout version, independent of the app's schema version. */
20
20
  export const FORMAT_VERSION = 1;
@@ -44,20 +44,23 @@ export async function ensureLedger(executor: Executor): Promise<void> {
44
44
  );
45
45
  }
46
46
 
47
- async function readMeta(executor: Executor, key: string): Promise<string | null> {
48
- const result = await executor.execute('SELECT value FROM _meta WHERE key = ?', [key]);
49
- const row = result.rows[0];
50
- return row ? text(row.value) : null;
51
- }
52
-
53
- export async function readSchemaVersion(executor: Executor): Promise<number> {
54
- const raw = await readMeta(executor, 'schema_version');
55
- return raw === null ? 0 : Number(raw);
56
- }
47
+ export type RecordedVersions = {
48
+ /** The app's schema version this scope has been migrated to. */
49
+ schema: number;
50
+ /** The physical layout this package wrote the file with. */
51
+ format: number;
52
+ };
57
53
 
58
- export async function readFormatVersion(executor: Executor): Promise<number> {
59
- const raw = await readMeta(executor, 'format_version');
60
- return raw === null ? FORMAT_VERSION : Number(raw);
54
+ /** Both versions in one read: every open asks for them. */
55
+ export async function readVersions(executor: Executor): Promise<RecordedVersions> {
56
+ const result = await executor.execute(
57
+ "SELECT key, value FROM _meta WHERE key IN ('schema_version', 'format_version')",
58
+ );
59
+ const values = new Map(result.rows.map(row => [text(row.key), Number(text(row.value))]));
60
+ return {
61
+ schema: values.get('schema_version') ?? 0,
62
+ format: values.get('format_version') ?? FORMAT_VERSION,
63
+ };
61
64
  }
62
65
 
63
66
  export async function writeSchemaVersion(executor: Executor, version: number): Promise<void> {
@@ -81,15 +84,3 @@ export async function recordAttempt(
81
84
  [entry.version, entry.attemptedAt, entry.outcome, entry.failureCode ?? null],
82
85
  );
83
86
  }
84
-
85
- export async function readLedger(executor: Executor): Promise<MigrationLedgerEntry[]> {
86
- const result = await executor.execute(
87
- 'SELECT version, attempted_at, outcome, failure_code FROM _migrations ORDER BY version ASC',
88
- );
89
- return result.rows.map(row => ({
90
- version: num(row.version),
91
- attemptedAt: num(row.attempted_at),
92
- outcome: text(row.outcome) as MigrationOutcome,
93
- ...(maybeText(row.failure_code) === null ? {} : { failureCode: text(row.failure_code) }),
94
- }));
95
- }
@@ -40,6 +40,8 @@ export type FileSystem = {
40
40
  readFile(path: string, encoding?: string): Promise<string>;
41
41
  /** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
42
42
  read(path: string, length: number, position: number, encoding?: string): Promise<string>;
43
+ /** One native call, no bytes through JS: how an unencrypted file becomes an artifact. */
44
+ copyFile(from: string, into: string): Promise<void>;
43
45
  stat(path: string): Promise<FileStat>;
44
46
  readdir(path: string): Promise<string[]>;
45
47
  unlink(path: string): Promise<void>;
@@ -71,14 +73,6 @@ export function fileSystem(): FileSystem {
71
73
  );
72
74
  }
73
75
 
74
- export async function pathExists(path: string): Promise<boolean> {
75
- try {
76
- return await fileSystem().exists(path);
77
- } catch {
78
- return false;
79
- }
80
- }
81
-
82
76
  /** Remove a path, treating "already gone" as success (FR-062). */
83
77
  export async function removeIfPresent(path: string): Promise<boolean> {
84
78
  try {
@@ -18,7 +18,6 @@ export const CATALOG_FILE = 'catalog.db';
18
18
  export const SCOPES_DIR = 'scopes';
19
19
  export const DATA_FILE = 'data.db';
20
20
  export const ARTIFACTS_DIR = 'artifacts';
21
- export const MARKER_FILE = '.localdb-marker';
22
21
 
23
22
  /** Normalize separators and collapse redundant segments. No `..` survives. */
24
23
  export function normalizePath(path: string): string {
@@ -46,7 +45,6 @@ export type StorageLayout = {
46
45
  scopeDataPath(scopeKey: string): string;
47
46
  scopeArtifactsPath(scopeKey: string): string;
48
47
  artifactPath(scopeKey: string, attachmentId: string): string;
49
- markerPath: string;
50
48
  };
51
49
 
52
50
  const SCOPE_KEY = /^[0-9a-f]{64}$/;
@@ -93,7 +91,6 @@ export function createLayout(documentDirectory: string, databaseName: string): S
93
91
  return {
94
92
  root,
95
93
  catalogPath: `${root}/${CATALOG_FILE}`,
96
- markerPath: `${root}/${MARKER_FILE}`,
97
94
  scopePath,
98
95
  scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
96
  scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
@@ -10,16 +10,42 @@ 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
+ /**
15
+ * UTF-8 length of a string, counted rather than encoded: measuring a 256 KB
16
+ * record used to allocate a 256 KB byte array just to read its length.
17
+ */
18
+ export function utf8Length(value: string): number {
19
+ let bytes = 0;
20
+ for (let index = 0; index < value.length; index += 1) {
21
+ const unit = value.charCodeAt(index);
22
+ if (unit < 0x80) {
23
+ bytes += 1;
24
+ } else if (unit < 0x800) {
25
+ bytes += 2;
26
+ } else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
27
+ // A surrogate pair is one four-byte code point.
28
+ bytes += 4;
29
+ index += 1;
30
+ } else {
31
+ bytes += 3;
32
+ }
33
+ }
34
+ return bytes;
35
+ }
14
36
 
15
37
  /** UTF-8 byte length of the serialized payload — what actually hits disk. */
16
38
  export function serializedSize(data: StorableRecordData): number {
17
- const json = JSON.stringify(data ?? {});
18
- return utf8Bytes(json).length;
39
+ return utf8Length(JSON.stringify(data ?? {}));
19
40
  }
20
41
 
21
- export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
22
- const size = serializedSize(data);
42
+ /**
43
+ * Checks the size budget and returns the serialized payload, so the write that
44
+ * follows stores this string instead of serializing the record a second time.
45
+ */
46
+ export function assertRecordWithinBudget(collection: string, data: StorableRecordData): string {
47
+ const payload = JSON.stringify(data ?? {});
48
+ const size = utf8Length(payload);
23
49
  if (size > Budgets.MAX_RECORD_BYTES) {
24
50
  // Truncating would be silent data loss; failing opaquely would leave
25
51
  // the consumer guessing. The size and the limit are both safe to
@@ -29,6 +55,7 @@ export function assertRecordWithinBudget(collection: string, data: StorableRecor
29
55
  detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
30
56
  });
31
57
  }
58
+ return payload;
32
59
  }
33
60
 
34
61
  export function assertBatchWithinBudget(count: number): void {