@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
@@ -1,89 +1,89 @@
1
- /**
2
- * The filesystem peer, behind one lazy accessor.
3
- *
4
- * Required lazily for the same reason the engine is: a consuming app that
5
- * only ever runs the in-memory adapter in tests must not have to install a
6
- * native module to import this package. Confining the require to `platform/`
7
- * also keeps the native surface four directories wide, which is the layering
8
- * rule the ESLint config enforces.
9
- */
10
-
11
- import { DiagnosticCodes } from '../contract/errors';
12
- import { raise } from '../diagnostics/errors';
13
-
14
- export type FileStat = {
15
- path: string;
16
- size: number;
17
- mtime: number;
18
- isFile(): boolean;
19
- isDirectory(): boolean;
20
- };
21
-
22
- export type MkdirOptions = {
23
- /** iOS only. Set at creation so nothing beneath it is ever backed up. */
24
- NSURLIsExcludedFromBackupKey?: boolean;
25
- };
26
-
27
- /**
28
- * Only what the package actually uses. The peer's upload and download
29
- * helpers are deliberately absent from this type: the package must never
30
- * make a network request (FR-084, SC-016), and a surface that cannot express
31
- * one is a stronger guarantee than a rule that says not to.
32
- */
33
- export type FileSystem = {
34
- DocumentDirectoryPath: string;
35
- mkdir(path: string, options?: MkdirOptions): Promise<void>;
36
- writeFile(path: string, contents: string, encoding?: string): Promise<void>;
37
- appendFile(path: string, contents: string, encoding?: string): Promise<void>;
38
- readFile(path: string, encoding?: string): Promise<string>;
39
- /** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
40
- read(path: string, length: number, position: number, encoding?: string): Promise<string>;
41
- stat(path: string): Promise<FileStat>;
42
- readdir(path: string): Promise<string[]>;
43
- unlink(path: string): Promise<void>;
44
- exists(path: string): Promise<boolean>;
45
- };
46
-
47
- let override: FileSystem | null = null;
48
- let cached: FileSystem | null = null;
49
-
50
- /** Swap the peer. Tests and the demo app's in-memory mode only. */
51
- export function setFileSystem(module: FileSystem | null): void {
52
- override = module;
53
- cached = null;
54
- }
55
-
56
- export function fileSystem(): FileSystem {
57
- if (override) {
58
- return override;
59
- }
60
- if (!cached) {
61
- try {
62
- cached = require('@dr.pogodin/react-native-fs') as FileSystem;
63
- } catch (error) {
64
- raise(DiagnosticCodes.STORAGE_ACCESS, {
65
- message: 'filesystem peer is not installed',
66
- cause: error,
67
- });
68
- }
69
- }
70
- return cached!;
71
- }
72
-
73
- export async function pathExists(path: string): Promise<boolean> {
74
- try {
75
- return await fileSystem().exists(path);
76
- } catch {
77
- return false;
78
- }
79
- }
80
-
81
- /** Remove a path, treating "already gone" as success (FR-062). */
82
- export async function removeIfPresent(path: string): Promise<boolean> {
83
- try {
84
- await fileSystem().unlink(path);
85
- return true;
86
- } catch {
87
- return false;
88
- }
89
- }
1
+ /**
2
+ * The filesystem peer, behind one lazy accessor.
3
+ *
4
+ * Required lazily for the same reason the engine is: a consuming app that
5
+ * only ever runs the in-memory adapter in tests must not have to install a
6
+ * native module to import this package. Confining the require to `platform/`
7
+ * also keeps the native surface four directories wide, which is the layering
8
+ * rule the ESLint config enforces.
9
+ */
10
+
11
+ import { DiagnosticCodes } from '../contract/errors';
12
+ import { raise } from '../diagnostics/errors';
13
+
14
+ export type FileStat = {
15
+ path: string;
16
+ size: number;
17
+ mtime: number;
18
+ isFile(): boolean;
19
+ isDirectory(): boolean;
20
+ };
21
+
22
+ export type MkdirOptions = {
23
+ /** iOS only. Set at creation so nothing beneath it is ever backed up. */
24
+ NSURLIsExcludedFromBackupKey?: boolean;
25
+ };
26
+
27
+ /**
28
+ * Only what the package actually uses. The peer's upload and download
29
+ * helpers are deliberately absent from this type: the package must never
30
+ * make a network request (FR-084, SC-016), and a surface that cannot express
31
+ * one is a stronger guarantee than a rule that says not to.
32
+ */
33
+ export type FileSystem = {
34
+ DocumentDirectoryPath: string;
35
+ mkdir(path: string, options?: MkdirOptions): Promise<void>;
36
+ writeFile(path: string, contents: string, encoding?: string): Promise<void>;
37
+ appendFile(path: string, contents: string, encoding?: string): Promise<void>;
38
+ readFile(path: string, encoding?: string): Promise<string>;
39
+ /** Chunked read at an offset — what FR-107a's flat memory bound rests on. */
40
+ read(path: string, length: number, position: number, encoding?: string): Promise<string>;
41
+ stat(path: string): Promise<FileStat>;
42
+ readdir(path: string): Promise<string[]>;
43
+ unlink(path: string): Promise<void>;
44
+ exists(path: string): Promise<boolean>;
45
+ };
46
+
47
+ let override: FileSystem | null = null;
48
+ let cached: FileSystem | null = null;
49
+
50
+ /** Swap the peer. Tests and the demo app's in-memory mode only. */
51
+ export function setFileSystem(module: FileSystem | null): void {
52
+ override = module;
53
+ cached = null;
54
+ }
55
+
56
+ export function fileSystem(): FileSystem {
57
+ if (override) {
58
+ return override;
59
+ }
60
+ if (!cached) {
61
+ try {
62
+ cached = require('@dr.pogodin/react-native-fs') as FileSystem;
63
+ } catch (error) {
64
+ raise(DiagnosticCodes.STORAGE_ACCESS, {
65
+ message: 'filesystem peer is not installed',
66
+ cause: error,
67
+ });
68
+ }
69
+ }
70
+ return cached!;
71
+ }
72
+
73
+ export async function pathExists(path: string): Promise<boolean> {
74
+ try {
75
+ return await fileSystem().exists(path);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /** Remove a path, treating "already gone" as success (FR-062). */
82
+ export async function removeIfPresent(path: string): Promise<boolean> {
83
+ try {
84
+ await fileSystem().unlink(path);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
@@ -1,135 +1,135 @@
1
- /**
2
- * The managed storage area, and the single guard every filesystem call routes
3
- * through.
4
- *
5
- * POL-011(b) flags media-and-files as a risk because this package deletes
6
- * files from disk on cascade. The mitigation is that there is exactly one
7
- * function that turns a caller's intent into a path, and it refuses anything
8
- * outside the root. One choke point is what makes SC-013a's filesystem
9
- * tracing pass reliably rather than usually.
10
- */
11
-
12
- import { DiagnosticCodes } from '../contract/errors';
13
- import { raise } from '../diagnostics/errors';
14
- import { fileSystem } from './fs';
15
-
16
- export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
17
- export const CATALOG_FILE = 'catalog.db';
18
- export const SCOPES_DIR = 'scopes';
19
- export const DATA_FILE = 'data.db';
20
- export const ARTIFACTS_DIR = 'artifacts';
21
- export const MARKER_FILE = '.localdb-marker';
22
-
23
- /** Normalize separators and collapse redundant segments. No `..` survives. */
24
- export function normalizePath(path: string): string {
25
- const unified = path.replace(/\\/g, '/');
26
- const segments: string[] = [];
27
- for (const segment of unified.split('/')) {
28
- if (segment === '' || segment === '.') {
29
- continue;
30
- }
31
- if (segment === '..') {
32
- segments.pop();
33
- continue;
34
- }
35
- segments.push(segment);
36
- }
37
- const prefix = unified.startsWith('/') ? '/' : '';
38
- return prefix + segments.join('/');
39
- }
40
-
41
- export type StorageLayout = {
42
- /** Absolute path to the managed root for one logical database. */
43
- root: string;
44
- catalogPath: string;
45
- scopePath(scopeKey: string): string;
46
- scopeDataPath(scopeKey: string): string;
47
- scopeArtifactsPath(scopeKey: string): string;
48
- artifactPath(scopeKey: string, attachmentId: string): string;
49
- markerPath: string;
50
- };
51
-
52
- const SCOPE_KEY = /^[0-9a-f]{64}$/;
53
- const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
54
- // `.` and `..` are made entirely of permitted characters and are the two that
55
- // matter: `artifacts/..` resolves to the scope directory itself, still inside
56
- // the managed root, so the boundary guard would wave it through and a cascade
57
- // would unlink the whole scope. A leading dot is refused with them, since no
58
- // attachment id needs one and hidden files are the package's own.
59
- const DOT_PREFIXED = /^\./;
60
-
61
- /**
62
- * An attachment id must be a safe single path segment.
63
- *
64
- * Shared by both adapters rather than left to the one that touches a
65
- * filesystem. The in-memory adapter has no paths to escape, but a consumer
66
- * whose tests accept `../../etc/passwd` and whose device build refuses it has
67
- * a test suite that stops predicting the device — which is the one thing the
68
- * adapter exists to avoid (FR-096).
69
- */
70
- export function assertValidAttachmentId(attachmentId: string): void {
71
- if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
72
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
73
- message: 'attachment id is not a valid path segment',
74
- });
75
- }
76
- }
77
-
78
- export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
79
- const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
80
-
81
- const scopePath = (scopeKey: string): string => {
82
- // The scope key is our own SHA-256 output. Validating its shape here
83
- // means a caller cannot smuggle a traversal through it even if some
84
- // future path reaches this function with unvalidated input.
85
- if (!SCOPE_KEY.test(scopeKey)) {
86
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
87
- message: 'scope key is not a valid digest',
88
- });
89
- }
90
- return `${root}/${SCOPES_DIR}/${scopeKey}`;
91
- };
92
-
93
- return {
94
- root,
95
- catalogPath: `${root}/${CATALOG_FILE}`,
96
- markerPath: `${root}/${MARKER_FILE}`,
97
- scopePath,
98
- scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
- scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
100
- artifactPath: (scopeKey, attachmentId) => {
101
- assertValidAttachmentId(attachmentId);
102
- return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
103
- },
104
- };
105
- }
106
-
107
- /**
108
- * The layout for one logical database, rooted at the app's private documents
109
- * directory.
110
- *
111
- * Resolved lazily through the filesystem peer rather than captured at module
112
- * load, so importing this package does not require the peer to be installed —
113
- * a consumer running only the in-memory adapter never reaches this function.
114
- */
115
- export function layoutFor(databaseName: string): StorageLayout {
116
- return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
117
- }
118
-
119
- /**
120
- * The guard. Every read, write, and delete passes through this, and anything
121
- * resolving outside the managed root is refused before it reaches the
122
- * filesystem (FR-073, FR-073f).
123
- */
124
- export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
125
- const normalized = normalizePath(candidate);
126
- const root = normalizePath(layout.root);
127
- if (normalized !== root && !normalized.startsWith(`${root}/`)) {
128
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
129
- // The offending path is NOT reported: it may contain a scope
130
- // component or a user-chosen filename.
131
- message: 'path resolves outside the managed storage area',
132
- });
133
- }
134
- return normalized;
135
- }
1
+ /**
2
+ * The managed storage area, and the single guard every filesystem call routes
3
+ * through.
4
+ *
5
+ * POL-011(b) flags media-and-files as a risk because this package deletes
6
+ * files from disk on cascade. The mitigation is that there is exactly one
7
+ * function that turns a caller's intent into a path, and it refuses anything
8
+ * outside the root. One choke point is what makes SC-013a's filesystem
9
+ * tracing pass reliably rather than usually.
10
+ */
11
+
12
+ import { DiagnosticCodes } from '../contract/errors';
13
+ import { raise } from '../diagnostics/errors';
14
+ import { fileSystem } from './fs';
15
+
16
+ export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
17
+ export const CATALOG_FILE = 'catalog.db';
18
+ export const SCOPES_DIR = 'scopes';
19
+ export const DATA_FILE = 'data.db';
20
+ export const ARTIFACTS_DIR = 'artifacts';
21
+ export const MARKER_FILE = '.localdb-marker';
22
+
23
+ /** Normalize separators and collapse redundant segments. No `..` survives. */
24
+ export function normalizePath(path: string): string {
25
+ const unified = path.replace(/\\/g, '/');
26
+ const segments: string[] = [];
27
+ for (const segment of unified.split('/')) {
28
+ if (segment === '' || segment === '.') {
29
+ continue;
30
+ }
31
+ if (segment === '..') {
32
+ segments.pop();
33
+ continue;
34
+ }
35
+ segments.push(segment);
36
+ }
37
+ const prefix = unified.startsWith('/') ? '/' : '';
38
+ return prefix + segments.join('/');
39
+ }
40
+
41
+ export type StorageLayout = {
42
+ /** Absolute path to the managed root for one logical database. */
43
+ root: string;
44
+ catalogPath: string;
45
+ scopePath(scopeKey: string): string;
46
+ scopeDataPath(scopeKey: string): string;
47
+ scopeArtifactsPath(scopeKey: string): string;
48
+ artifactPath(scopeKey: string, attachmentId: string): string;
49
+ markerPath: string;
50
+ };
51
+
52
+ const SCOPE_KEY = /^[0-9a-f]{64}$/;
53
+ const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
54
+ // `.` and `..` are made entirely of permitted characters and are the two that
55
+ // matter: `artifacts/..` resolves to the scope directory itself, still inside
56
+ // the managed root, so the boundary guard would wave it through and a cascade
57
+ // would unlink the whole scope. A leading dot is refused with them, since no
58
+ // attachment id needs one and hidden files are the package's own.
59
+ const DOT_PREFIXED = /^\./;
60
+
61
+ /**
62
+ * An attachment id must be a safe single path segment.
63
+ *
64
+ * Shared by both adapters rather than left to the one that touches a
65
+ * filesystem. The in-memory adapter has no paths to escape, but a consumer
66
+ * whose tests accept `../../etc/passwd` and whose device build refuses it has
67
+ * a test suite that stops predicting the device — which is the one thing the
68
+ * adapter exists to avoid (FR-096).
69
+ */
70
+ export function assertValidAttachmentId(attachmentId: string): void {
71
+ if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
72
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
73
+ message: 'attachment id is not a valid path segment',
74
+ });
75
+ }
76
+ }
77
+
78
+ export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
79
+ const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
80
+
81
+ const scopePath = (scopeKey: string): string => {
82
+ // The scope key is our own SHA-256 output. Validating its shape here
83
+ // means a caller cannot smuggle a traversal through it even if some
84
+ // future path reaches this function with unvalidated input.
85
+ if (!SCOPE_KEY.test(scopeKey)) {
86
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
87
+ message: 'scope key is not a valid digest',
88
+ });
89
+ }
90
+ return `${root}/${SCOPES_DIR}/${scopeKey}`;
91
+ };
92
+
93
+ return {
94
+ root,
95
+ catalogPath: `${root}/${CATALOG_FILE}`,
96
+ markerPath: `${root}/${MARKER_FILE}`,
97
+ scopePath,
98
+ scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
+ scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
100
+ artifactPath: (scopeKey, attachmentId) => {
101
+ assertValidAttachmentId(attachmentId);
102
+ return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The layout for one logical database, rooted at the app's private documents
109
+ * directory.
110
+ *
111
+ * Resolved lazily through the filesystem peer rather than captured at module
112
+ * load, so importing this package does not require the peer to be installed —
113
+ * a consumer running only the in-memory adapter never reaches this function.
114
+ */
115
+ export function layoutFor(databaseName: string): StorageLayout {
116
+ return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
117
+ }
118
+
119
+ /**
120
+ * The guard. Every read, write, and delete passes through this, and anything
121
+ * resolving outside the managed root is refused before it reaches the
122
+ * filesystem (FR-073, FR-073f).
123
+ */
124
+ export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
125
+ const normalized = normalizePath(candidate);
126
+ const root = normalizePath(layout.root);
127
+ if (normalized !== root && !normalized.startsWith(`${root}/`)) {
128
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
129
+ // The offending path is NOT reported: it may contain a scope
130
+ // component or a user-chosen filename.
131
+ message: 'path resolves outside the managed storage area',
132
+ });
133
+ }
134
+ return normalized;
135
+ }
@@ -1,57 +1,54 @@
1
- /**
2
- * Budget enforcement.
3
- *
4
- * Per-record size, page size, and batch size are HARD limits reported through
5
- * typed errors (FR-088a). The rest are soft: exceeding them must not crash,
6
- * hang, or drop data silently, and the degraded behavior is documented.
7
- */
8
-
9
- import { Budgets } from '../contract/config';
10
- import { DiagnosticCodes } from '../contract/errors';
11
- import type { StorableRecordData } from '../contract/record';
12
- import { raise } from '../diagnostics/errors';
13
-
14
- /** UTF-8 byte length of the serialized payload — what actually hits disk. */
15
- export function serializedSize(data: StorableRecordData): number {
16
- 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');
22
- }
23
-
24
- export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
25
- const size = serializedSize(data);
26
- if (size > Budgets.MAX_RECORD_BYTES) {
27
- // Truncating would be silent data loss; failing opaquely would leave
28
- // the consumer guessing. The size and the limit are both safe to
29
- // report — neither can carry a field value.
30
- raise(DiagnosticCodes.RECORD_TOO_LARGE, {
31
- message: 'record exceeds the per-record size budget',
32
- detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
33
- });
34
- }
35
- }
36
-
37
- export function assertBatchWithinBudget(count: number): void {
38
- if (count > Budgets.MAX_BATCH_OPERATIONS) {
39
- raise(DiagnosticCodes.BATCH_TOO_LARGE, {
40
- message: 'batch exceeds the operation budget',
41
- detail: { count, limit: Budgets.MAX_BATCH_OPERATIONS },
42
- });
43
- }
44
- }
45
-
46
- export function assertArtifactWithinBudget(id: string, size: number): void {
47
- if (size > Budgets.MAX_ARTIFACT_BYTES) {
48
- raise(DiagnosticCodes.ATTACHMENT_WRITE_FAILED, {
49
- message: 'artifact exceeds the size budget',
50
- detail: {
51
- attachmentId: id,
52
- sizeBytes: size,
53
- limitBytes: Budgets.MAX_ARTIFACT_BYTES,
54
- },
55
- });
56
- }
57
- }
1
+ /**
2
+ * Budget enforcement.
3
+ *
4
+ * Per-record size, page size, and batch size are HARD limits reported through
5
+ * typed errors (FR-088a). The rest are soft: exceeding them must not crash,
6
+ * hang, or drop data silently, and the degraded behavior is documented.
7
+ */
8
+
9
+ import { Budgets } from '../contract/config';
10
+ import { DiagnosticCodes } from '../contract/errors';
11
+ import type { StorableRecordData } from '../contract/record';
12
+ import { raise } from '../diagnostics/errors';
13
+ import { utf8Bytes } from '../platform/bytes';
14
+
15
+ /** UTF-8 byte length of the serialized payload — what actually hits disk. */
16
+ export function serializedSize(data: StorableRecordData): number {
17
+ const json = JSON.stringify(data ?? {});
18
+ return utf8Bytes(json).length;
19
+ }
20
+
21
+ export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
22
+ const size = serializedSize(data);
23
+ if (size > Budgets.MAX_RECORD_BYTES) {
24
+ // Truncating would be silent data loss; failing opaquely would leave
25
+ // the consumer guessing. The size and the limit are both safe to
26
+ // report — neither can carry a field value.
27
+ raise(DiagnosticCodes.RECORD_TOO_LARGE, {
28
+ message: 'record exceeds the per-record size budget',
29
+ detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
30
+ });
31
+ }
32
+ }
33
+
34
+ export function assertBatchWithinBudget(count: number): void {
35
+ if (count > Budgets.MAX_BATCH_OPERATIONS) {
36
+ raise(DiagnosticCodes.BATCH_TOO_LARGE, {
37
+ message: 'batch exceeds the operation budget',
38
+ detail: { count, limit: Budgets.MAX_BATCH_OPERATIONS },
39
+ });
40
+ }
41
+ }
42
+
43
+ export function assertArtifactWithinBudget(id: string, size: number): void {
44
+ if (size > Budgets.MAX_ARTIFACT_BYTES) {
45
+ raise(DiagnosticCodes.ATTACHMENT_WRITE_FAILED, {
46
+ message: 'artifact exceeds the size budget',
47
+ detail: {
48
+ attachmentId: id,
49
+ sizeBytes: size,
50
+ limitBytes: Budgets.MAX_ARTIFACT_BYTES,
51
+ },
52
+ });
53
+ }
54
+ }