diff --git a/docs/docs/stdlib-persistence.md b/docs/docs/stdlib-persistence.md
index 9f847600a..63eef532e 100644
--- a/docs/docs/stdlib-persistence.md
+++ b/docs/docs/stdlib-persistence.md
@@ -23,6 +23,11 @@ removing them. Store names, entry keys, values, serialized records, store and
entry counts, browser-key scans, and the aggregate UTF-16/UTF-8 payload are all
bounded. Truncation and corruption are reported as snapshot metadata so an
inspector cannot wedge application persistence or perform unbounded work.
+When `PersistentStore` is loaded in a debug runtime, this snapshot is published
+to the generic debugger Data panel as the read-only `persistent-store`
+provider. The adapter applies a smaller 43 KiB transport projection and reports
+the exact known store and entry omissions. Native bindings that do not expose
+the snapshot callback advertise the provider as unavailable.
## Installation
@@ -445,4 +450,3 @@ The `persistence` module works on:
- **Encryption has overhead** - only use for sensitive data
- **LRU caching helps** - use maxWeight to limit storage usage
- **TTL prevents bloat** - set reasonable expiration times
-
diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md
index bcbca836a..515cbb168 100644
--- a/npm_modules/cli/debugger/README.md
+++ b/npm_modules/cli/debugger/README.md
@@ -63,13 +63,11 @@ Web-renderer inspection uses the first-party bridge exposed as
configured loopback Chromium target.
The Data section discovers target-owned providers through a generic custom
-message contract. This slice includes read-only Storage and SQL presentation,
-but it does not register either backend. A later thin registration layer can
-adapt a bounded Storage snapshot callback or an existing SQL API without
-changing this contract. Until then, the UI reports both surfaces as unavailable
-instead of synthesizing sample data. Network and key-value integrations are not
-part of this slice and remain unavailable unless a future runtime provider is
-registered.
+message contract. The persistence module registers its bounded web snapshot as
+the `persistent-store` Storage provider and reports it unavailable on platforms
+whose native binding does not expose that inspector. SQL, Network, and
+key-value integrations remain unavailable unless a runtime provider registers
+them; the UI never synthesizes sample data.
Runtime adapters cross the provider boundary with an already serialized JSON
object document, not an arbitrary object graph. They should call
@@ -82,11 +80,12 @@ the complete serialized custom-response body including metadata. Owners bind
to the creating module object's hot-reload callback automatically, including
webpack modules where `module.path` is absent. Adapters call
`owner.dispose()` only when stopping before a reload. A newer registration for
-an existing provider ID permanently invalidates the older registration. This
-is the integration point for the later thin Storage/SQL registration layer.
-On native runtimes the owner observes `module.path`; the explicit stable key is
-replacement identity only. Web runtimes fall back to observing that stable key
-because webpack does not provide `module.path`.
+an existing provider ID permanently invalidates the older registration. The
+PersistentStore adapter projects known data properties into a deterministic
+43 KiB document before calling this helper. On native runtimes the owner
+observes `module.path`; the explicit stable key is replacement identity only.
+Web runtimes fall back to observing that stable key because webpack does not
+provide `module.path`.
Published settings are application-owned controls registered only in debug
runtimes. Values are limited to declared toggle, select, text, and number
diff --git a/npm_modules/cli/debugger/debugger-providers.js b/npm_modules/cli/debugger/debugger-providers.js
index aa7bd9559..abc0fd5db 100644
--- a/npm_modules/cli/debugger/debugger-providers.js
+++ b/npm_modules/cli/debugger/debugger-providers.js
@@ -248,12 +248,29 @@ function renderProviderStatus(provider, unavailableMessage) {
function renderStorageEntry(entry) {
const truncated = entry.valueTruncated || entry.keyTruncated;
- const metadata = [entry.encoding || 'unknown'];
+ const metadata = [entry.encoding ?? 'unknown'];
if (entry.byteLength !== undefined) metadata.push(`${entry.byteLength} bytes`);
if (truncated) metadata.push('truncated');
return `${escapeHtml(entry.key || '')}${escapeHtml(metadata.join(' · '))}
${escapeHtml(entry.value ?? '')} `;
}
+function renderStorageProjectionNote(storage) {
+ const projection = storage?.projection;
+ if (projection?.truncated !== true) return '';
+ const details = [];
+ const storesOmitted = Number(projection.storesOmitted);
+ const entriesOmitted = Number(projection.entriesOmitted);
+ const truncatedFields = Number(projection.truncatedFields);
+ const invalidFields = Number(projection.invalidFields);
+ if (Number.isInteger(storesOmitted) && storesOmitted > 0) details.push(`${storesOmitted} ${storesOmitted === 1 ? 'store' : 'stores'} omitted`);
+ if (Number.isInteger(entriesOmitted) && entriesOmitted > 0) details.push(`${entriesOmitted} ${entriesOmitted === 1 ? 'entry' : 'entries'} omitted`);
+ if (projection.sourceEntryCountIncomplete === true) details.push('source entry total incomplete');
+ if (Number.isInteger(truncatedFields) && truncatedFields > 0) details.push(`${truncatedFields} ${truncatedFields === 1 ? 'field' : 'fields'} truncated`);
+ if (Number.isInteger(invalidFields) && invalidFields > 0) details.push(`${invalidFields} invalid ${invalidFields === 1 ? 'field' : 'fields'} omitted`);
+ const detail = details.length ? `: ${details.join(', ')}` : '';
+ return `
The Storage transport projection was truncated${escapeHtml(detail)}.
`;
+}
+
function renderStoragePanel() {
const provider = debuggerProviderForKind('storage');
const unavailable = debuggerProviderUnavailableMessage('storage', 'Storage');
@@ -261,10 +278,11 @@ function renderStoragePanel() {
return `${renderProviderStatus(provider, unavailable)}${escapeHtml(unavailable)}
`;
}
const stores = Array.isArray(state.providers.storage.stores) ? state.providers.storage.stores : [];
- if (!stores.length) return `${renderProviderStatus(provider, unavailable)}The registered Storage provider returned no stores.
`;
- return `${renderProviderStatus(provider, unavailable)}${stores.map(store => {
+ const storageIssues = `${state.providers.storage.storageError ? `
${escapeHtml(state.providers.storage.storageError)}
` : ''}${state.providers.storage.storageInspectionTruncated ? '
Persistent browser-storage discovery was truncated by the inspection budget.
' : ''}${renderStorageProjectionNote(state.providers.storage)}`;
+ if (!stores.length) return `${renderProviderStatus(provider, unavailable)}${storageIssues}
The registered Storage provider returned no stores.
`;
+ return `${renderProviderStatus(provider, unavailable)}${storageIssues}
${stores.map(store => {
const entries = Array.isArray(store.entries) ? store.entries : [];
- return `
${escapeHtml(store.name || 'Storage')}${escapeHtml(store.scope || 'unknown')} · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}
${store.error ? `${escapeHtml(store.error)}
` : ''}${entries.length ? entries.map(renderStorageEntry).join('') : 'This store is empty.
'}${store.entriesTruncated ? 'Additional entries were omitted by the debugger snapshot budget.
' : ''}`;
+ return `
${escapeHtml(store.name || 'Storage')}${escapeHtml(store.backend ?? 'unknown')} · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}
${store.error ? `${escapeHtml(store.error)}
` : ''}${entries.length ? entries.map(renderStorageEntry).join('') : 'This store is empty.
'}${store.entriesTruncated ? 'Additional entries were omitted by the debugger snapshot budget.
' : ''}${store.inspectionTruncated ? 'Entry inspection stopped at the debugger scan limit.
' : ''}`;
}).join('')}
`;
}
diff --git a/npm_modules/cli/src/debugger/browserTools.spec.ts b/npm_modules/cli/src/debugger/browserTools.spec.ts
index b184b80f4..69f7c1b1e 100644
--- a/npm_modules/cli/src/debugger/browserTools.spec.ts
+++ b/npm_modules/cli/src/debugger/browserTools.spec.ts
@@ -214,4 +214,70 @@ describe('debugger browser provider and settings tools', () => {
'SQL support is not registered by this target runtime.',
);
});
+
+ it('renders bounded PersistentStore diagnostics without losing numeric string encoding', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: { registry: Record
; storage: Record };
+ };
+ state.providers.registry = {
+ providers: [{ available: true, id: 'persistent-store', kind: 'storage', label: 'PersistentStore' }],
+ };
+ state.providers.storage = {
+ storageError: 'Storage access was denied.',
+ storageInspectionTruncated: true,
+ stores: [
+ {
+ backend: 'memory',
+ entries: [{ encoding: 0, key: 'theme', value: 'dark' }],
+ inspectionTruncated: true,
+ name: 'preferences',
+ },
+ ],
+ };
+
+ const html = call(harness.context, 'renderStoragePanel');
+ expect(html).toContain('Storage access was denied.');
+ expect(html).toContain('Persistent browser-storage discovery was truncated');
+ expect(html).toContain('memory · 1 entry');
+ expect(html).toContain('0');
+ expect(html).toContain('Entry inspection stopped at the debugger scan limit.');
+ expect(html).not.toContain('unknown · 1 entry');
+ });
+
+ it('surfaces provider projection omissions globally and on the affected store', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: { registry: Record; storage: Record };
+ };
+ state.providers.registry = {
+ providers: [{ available: true, id: 'persistent-store', kind: 'storage', label: 'PersistentStore' }],
+ };
+ state.providers.storage = {
+ projection: {
+ entriesOmitted: 60,
+ invalidFields: 0,
+ sourceEntries: 100,
+ sourceStores: 2,
+ storesOmitted: 1,
+ truncated: true,
+ truncatedFields: 1,
+ },
+ stores: [
+ {
+ backend: 'memory',
+ entries: [{ encoding: 0, key: 'first', value: 'value' }],
+ entriesTruncated: true,
+ name: 'bounded',
+ },
+ ],
+ };
+
+ const html = call(harness.context, 'renderStoragePanel');
+ expect(html).toContain('Storage transport projection was truncated');
+ expect(html).toContain('1 store omitted');
+ expect(html).toContain('60 entries omitted');
+ expect(html).toContain('1 field truncated');
+ expect(html).toContain('Additional entries were omitted by the debugger snapshot budget.');
+ });
});
diff --git a/src/valdi_modules/src/valdi/persistence/src/PersistentStore.ts b/src/valdi_modules/src/valdi/persistence/src/PersistentStore.ts
index 858238c2f..c6cbbcddc 100644
--- a/src/valdi_modules/src/valdi/persistence/src/PersistentStore.ts
+++ b/src/valdi_modules/src/valdi/persistence/src/PersistentStore.ts
@@ -2,6 +2,7 @@ import { toError } from 'valdi_core/src/utils/ErrorUtils';
import { makeSingleCallInterruptibleCallback } from 'valdi_core/src/utils/FunctionUtils';
import { PropertyList } from 'valdi_core/src/utils/PropertyList';
import { PersistentStoreNative } from './PersistentStoreNative';
+import './PersistentStoreDebuggerProvider';
declare function require(path: string): any;
const nativeCreate: (
diff --git a/src/valdi_modules/src/valdi/persistence/src/PersistentStoreDebuggerProvider.ts b/src/valdi_modules/src/valdi/persistence/src/PersistentStoreDebuggerProvider.ts
new file mode 100644
index 000000000..ed2648fa8
--- /dev/null
+++ b/src/valdi_modules/src/valdi/persistence/src/PersistentStoreDebuggerProvider.ts
@@ -0,0 +1,627 @@
+import {
+ createDebuggerProviderOwner,
+ createDebuggerProviderResult,
+ DebuggerProviderKind,
+} from 'valdi_core/src/debugging/DebuggerProvider';
+import type {
+ DebuggerProvider,
+ DebuggerProviderModule,
+ DebuggerProviderOwner,
+ DebuggerProviderRequest,
+ DebuggerProviderResult,
+} from 'valdi_core/src/debugging/DebuggerProvider';
+
+declare const module: { readonly path?: string };
+declare function require(path: string): unknown;
+
+const PERSISTENT_STORE_PROVIDER_ID = 'persistent-store';
+const PERSISTENT_STORE_PROVIDER_OWNER_KEY = 'persistence/src/PersistentStoreDebuggerProvider';
+const MAX_PROVIDER_DOCUMENT_BYTES = 43 * 1024;
+const MAX_PROVIDER_STORES = 100;
+const MAX_PROVIDER_ENTRIES_PER_STORE = 100;
+const MAX_PROVIDER_ENTRIES = 500;
+const MAX_DEBUGGER_STRING_CHARACTERS = 32 * 1024;
+const MAX_ERROR_CHARACTERS = 1024;
+const MAX_KEY_CHARACTERS = 1024;
+const MAX_NAME_CHARACTERS = 512;
+
+interface OwnDataProperty {
+ readonly present: boolean;
+ readonly valid: boolean;
+ readonly value?: unknown;
+}
+
+interface BoundedStringProperty {
+ readonly originalLength?: number;
+ readonly truncated: boolean;
+ readonly value?: string;
+}
+
+interface ProjectionState {
+ invalidFields: number;
+ outputEntries: number;
+ sourceEntries: number;
+ sourceEntryCountIncomplete: boolean;
+ sourceStores: number;
+ stoppedForBudget: boolean;
+ truncatedFields: number;
+}
+
+interface SourceStore {
+ readonly entries?: readonly unknown[];
+ readonly entryCount: number;
+ readonly store: object;
+}
+
+interface ProjectedEntry {
+ readonly output: Record;
+ readonly valueTruncatedByCharacterLimit: boolean;
+}
+
+interface PersistentStoreProjectionMetadata {
+ entriesOmitted: number;
+ readonly entryLimit: number;
+ invalidFields: number;
+ readonly maxDocumentBytes: number;
+ readonly returnedEntries: number;
+ readonly returnedStores: number;
+ readonly sourceEntries: number;
+ readonly sourceEntryCountIncomplete?: boolean;
+ readonly sourceStores: number;
+ readonly storeLimit: number;
+ storesOmitted: number;
+ truncated: boolean;
+ truncatedFields: number;
+}
+
+interface PersistentStoreProjection {
+ diagnostics?: Record;
+ inspectedStorageKeys?: number;
+ limits?: Record;
+ projection: PersistentStoreProjectionMetadata;
+ rejectedStorageKeys?: number;
+ storageError?: string;
+ storageErrorLength?: number;
+ storageErrorTruncated?: boolean;
+ storageInspectionTruncated?: boolean;
+ stores: Record[];
+ truncated: boolean;
+ usage?: Record;
+}
+
+function ownDataProperty(value: unknown, key: PropertyKey): OwnDataProperty {
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
+ return { present: false, valid: false };
+ }
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
+ } catch {
+ return { present: false, valid: false };
+ }
+ if (descriptor === undefined) return { present: false, valid: true };
+ if (!('value' in descriptor)) return { present: true, valid: false };
+ return { present: true, valid: true, value: descriptor.value };
+}
+
+function noteInvalid(state: ProjectionState, property: OwnDataProperty): void {
+ if (!property.valid) state.invalidFields++;
+}
+
+function safeArray(value: unknown): readonly unknown[] | undefined {
+ try {
+ return Array.isArray(value) ? value : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function safeArrayLength(value: readonly unknown[], state: ProjectionState): number {
+ const length = ownDataProperty(value, 'length');
+ if (!length.valid || typeof length.value !== 'number' || !Number.isInteger(length.value) || length.value < 0) {
+ state.invalidFields++;
+ state.sourceEntryCountIncomplete = true;
+ return 0;
+ }
+ return length.value;
+}
+
+function safeArrayItem(value: readonly unknown[], index: number, state: ProjectionState): unknown {
+ const item = ownDataProperty(value, index.toString());
+ noteInvalid(state, item);
+ return item.valid ? item.value : undefined;
+}
+
+function safeStringPrefix(value: string, maximumCharacters: number): string {
+ let end = Math.min(value.length, maximumCharacters);
+ if (
+ end > 0 &&
+ end < value.length &&
+ value.charCodeAt(end - 1) >= 0xd800 &&
+ value.charCodeAt(end - 1) <= 0xdbff &&
+ value.charCodeAt(end) >= 0xdc00 &&
+ value.charCodeAt(end) <= 0xdfff
+ ) {
+ end--;
+ }
+ return value.slice(0, end);
+}
+
+function boundedStringProperty(
+ value: object,
+ key: PropertyKey,
+ maximumCharacters: number,
+ state: ProjectionState,
+): BoundedStringProperty {
+ const property = ownDataProperty(value, key);
+ noteInvalid(state, property);
+ if (!property.present || !property.valid) return { truncated: false };
+ if (typeof property.value !== 'string') {
+ state.invalidFields++;
+ return { truncated: false };
+ }
+ const bounded = safeStringPrefix(property.value, maximumCharacters);
+ const truncated = bounded.length !== property.value.length;
+ if (truncated) state.truncatedFields++;
+ return { originalLength: property.value.length, truncated, value: bounded };
+}
+
+function stringProperty(
+ value: object,
+ key: PropertyKey,
+ maximumCharacters: number,
+ state: ProjectionState,
+): string | undefined {
+ return boundedStringProperty(value, key, maximumCharacters, state).value;
+}
+
+function booleanProperty(value: object, key: PropertyKey, state: ProjectionState): boolean | undefined {
+ const property = ownDataProperty(value, key);
+ noteInvalid(state, property);
+ if (!property.present || !property.valid) return undefined;
+ if (typeof property.value !== 'boolean') {
+ state.invalidFields++;
+ return undefined;
+ }
+ return property.value;
+}
+
+function numberProperty(value: object, key: PropertyKey, state: ProjectionState): number | undefined {
+ const property = ownDataProperty(value, key);
+ noteInvalid(state, property);
+ if (!property.present || !property.valid) return undefined;
+ if (typeof property.value !== 'number' || !Number.isFinite(property.value)) {
+ state.invalidFields++;
+ return undefined;
+ }
+ return property.value;
+}
+
+function copyOptionalString(
+ output: Record,
+ source: object,
+ key: string,
+ maximumCharacters: number,
+ state: ProjectionState,
+): void {
+ const value = stringProperty(source, key, maximumCharacters, state);
+ if (value !== undefined) output[key] = value;
+}
+
+function copyOptionalBoolean(
+ output: Record,
+ source: object,
+ key: string,
+ state: ProjectionState,
+): void {
+ const value = booleanProperty(source, key, state);
+ if (value !== undefined) output[key] = value;
+}
+
+function copyOptionalNumber(
+ output: Record,
+ source: object,
+ key: string,
+ state: ProjectionState,
+): void {
+ const value = numberProperty(source, key, state);
+ if (value !== undefined) output[key] = value;
+}
+
+function knownNumberRecord(
+ source: object,
+ keys: readonly string[],
+ state: ProjectionState,
+): Record | undefined {
+ const output: Record = Object.create(null) as Record;
+ let count = 0;
+ keys.forEach(key => {
+ const value = numberProperty(source, key, state);
+ if (value !== undefined) {
+ output[key] = value;
+ count++;
+ }
+ });
+ return count === 0 ? undefined : output;
+}
+
+function diagnosticsRecord(source: object, state: ProjectionState): Record | undefined {
+ const hydratedStores = numberProperty(source, 'hydratedStores', state);
+ const memoryStores = numberProperty(source, 'memoryStores', state);
+ const storageAvailable = booleanProperty(source, 'storageAvailable', state);
+ if (hydratedStores === undefined && memoryStores === undefined && storageAvailable === undefined) return undefined;
+ return {
+ ...(hydratedStores === undefined ? {} : { hydratedStores }),
+ ...(memoryStores === undefined ? {} : { memoryStores }),
+ ...(storageAvailable === undefined ? {} : { storageAvailable }),
+ };
+}
+
+function objectProperty(value: object, key: PropertyKey, state: ProjectionState): object | undefined {
+ const property = ownDataProperty(value, key);
+ noteInvalid(state, property);
+ if (!property.present || !property.valid) return undefined;
+ if (typeof property.value !== 'object' || property.value === null || safeArray(property.value) !== undefined) {
+ state.invalidFields++;
+ return undefined;
+ }
+ return property.value;
+}
+
+function sourceStores(snapshot: object, state: ProjectionState): SourceStore[] {
+ const storesProperty = ownDataProperty(snapshot, 'stores');
+ noteInvalid(state, storesProperty);
+ const stores = safeArray(storesProperty.value);
+ if (!storesProperty.present || stores === undefined) {
+ if (storesProperty.present && storesProperty.valid) state.invalidFields++;
+ return [];
+ }
+ const sourceStoreCount = safeArrayLength(stores, state);
+ state.sourceStores = sourceStoreCount;
+ if (sourceStoreCount > MAX_PROVIDER_STORES) state.sourceEntryCountIncomplete = true;
+ const output: SourceStore[] = [];
+ const inspectedStoreCount = Math.min(sourceStoreCount, MAX_PROVIDER_STORES);
+ for (let index = 0; index < inspectedStoreCount; index++) {
+ const storeValue = safeArrayItem(stores, index, state);
+ if (typeof storeValue !== 'object' || storeValue === null) {
+ state.invalidFields++;
+ state.sourceEntryCountIncomplete = true;
+ continue;
+ }
+ const entriesProperty = ownDataProperty(storeValue, 'entries');
+ noteInvalid(state, entriesProperty);
+ const entries = safeArray(entriesProperty.value);
+ if (entriesProperty.present && entriesProperty.valid && entries === undefined) state.invalidFields++;
+ if (entries === undefined) state.sourceEntryCountIncomplete = true;
+ const entryCount = entries === undefined ? 0 : safeArrayLength(entries, state);
+ state.sourceEntries += entryCount;
+ output.push({ entries, entryCount, store: storeValue });
+ }
+ return output;
+}
+
+function projectedEntry(source: unknown, state: ProjectionState): ProjectedEntry {
+ const output: Record = Object.create(null) as Record;
+ if (typeof source !== 'object' || source === null) {
+ state.invalidFields++;
+ output.encoding = 'unknown';
+ output.key = '';
+ output.value = '';
+ output.unavailableReason = 'invalid-debug-entry';
+ return { output, valueTruncatedByCharacterLimit: false };
+ }
+ const encoding = numberProperty(source, 'encoding', state);
+ const key = boundedStringProperty(source, 'key', MAX_KEY_CHARACTERS, state);
+ const value = boundedStringProperty(source, 'value', MAX_DEBUGGER_STRING_CHARACTERS, state);
+ output.encoding = encoding === undefined ? 'unknown' : encoding;
+ output.key = key.value ?? '';
+ output.value = value.value ?? '';
+ copyOptionalNumber(output, source, 'expiresAt', state);
+ copyOptionalNumber(output, source, 'keyLength', state);
+ copyOptionalBoolean(output, source, 'keyTruncated', state);
+ copyOptionalString(output, source, 'unavailableReason', MAX_ERROR_CHARACTERS, state);
+ copyOptionalNumber(output, source, 'valueLength', state);
+ copyOptionalBoolean(output, source, 'valueTruncated', state);
+ copyOptionalNumber(output, source, 'weight', state);
+ if (key.truncated) {
+ output.keyLength = Math.max(typeof output.keyLength === 'number' ? output.keyLength : 0, key.originalLength ?? 0);
+ output.keyTruncated = true;
+ }
+ if (value.truncated) {
+ output.valueLength = Math.max(
+ typeof output.valueLength === 'number' ? output.valueLength : 0,
+ value.originalLength ?? 0,
+ );
+ output.valueTruncated = true;
+ }
+ return { output, valueTruncatedByCharacterLimit: value.truncated };
+}
+
+function projectedStore(source: object, state: ProjectionState): Record {
+ const output: Record = Object.create(null) as Record;
+ output.backend = stringProperty(source, 'backend', 64, state) ?? 'unknown';
+ output.entries = [];
+ output.inspectedEntries = numberProperty(source, 'inspectedEntries', state) ?? 0;
+ output.name = stringProperty(source, 'name', MAX_NAME_CHARACTERS, state) ?? 'Storage';
+ copyOptionalBoolean(output, source, 'entriesTruncated', state);
+ copyOptionalString(output, source, 'error', MAX_ERROR_CHARACTERS, state);
+ copyOptionalNumber(output, source, 'errorLength', state);
+ copyOptionalBoolean(output, source, 'errorTruncated', state);
+ copyOptionalBoolean(output, source, 'inspectionTruncated', state);
+ copyOptionalNumber(output, source, 'nameLength', state);
+ copyOptionalBoolean(output, source, 'nameTruncated', state);
+ copyOptionalNumber(output, source, 'serializedLength', state);
+ return output;
+}
+
+function utf8ByteLength(value: string): number {
+ let bytes = 0;
+ for (let index = 0; index < value.length; index++) {
+ const code = value.charCodeAt(index);
+ if (code <= 0x7f) bytes++;
+ else if (code <= 0x7ff) bytes += 2;
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
+ const next = value.charCodeAt(index + 1);
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ bytes += 4;
+ index++;
+ } else bytes += 3;
+ } else bytes += 3;
+ }
+ return bytes;
+}
+
+function projectionFits(output: PersistentStoreProjection): boolean {
+ return utf8ByteLength(JSON.stringify(output)) <= MAX_PROVIDER_DOCUMENT_BYTES;
+}
+
+function updateProjectionMetadata(
+ output: PersistentStoreProjection,
+ state: ProjectionState,
+ sourceTruncated: boolean,
+): void {
+ const storesOmitted = Math.max(0, state.sourceStores - output.stores.length);
+ const entriesOmitted = Math.max(0, state.sourceEntries - state.outputEntries);
+ const truncated =
+ sourceTruncated ||
+ storesOmitted > 0 ||
+ entriesOmitted > 0 ||
+ state.invalidFields > 0 ||
+ state.sourceEntryCountIncomplete ||
+ state.stoppedForBudget ||
+ state.truncatedFields > 0;
+ output.projection = {
+ entriesOmitted,
+ entryLimit: MAX_PROVIDER_ENTRIES,
+ invalidFields: state.invalidFields,
+ maxDocumentBytes: MAX_PROVIDER_DOCUMENT_BYTES,
+ returnedEntries: state.outputEntries,
+ returnedStores: output.stores.length,
+ sourceEntries: state.sourceEntries,
+ ...(state.sourceEntryCountIncomplete ? { sourceEntryCountIncomplete: true } : {}),
+ sourceStores: state.sourceStores,
+ storeLimit: MAX_PROVIDER_STORES,
+ storesOmitted,
+ truncated,
+ truncatedFields: state.truncatedFields,
+ };
+ output.truncated = truncated;
+}
+
+function largestFittingValuePrefix(
+ output: PersistentStoreProjection,
+ entry: Record,
+ originalValue: string,
+ valueTruncationAlreadyCounted: boolean,
+ state: ProjectionState,
+ sourceTruncated: boolean,
+): string | undefined {
+ let low = 0;
+ let high = originalValue.length;
+ let best: string | undefined;
+ const sourceValueLength =
+ typeof entry.valueLength === 'number' ? Math.max(originalValue.length, entry.valueLength) : originalValue.length;
+ if (!valueTruncationAlreadyCounted) state.truncatedFields++;
+ while (low <= high) {
+ const midpoint = Math.floor((low + high) / 2);
+ const candidate = safeStringPrefix(originalValue, midpoint);
+ entry.value = candidate;
+ entry.valueLength = sourceValueLength;
+ entry.valueTruncated = true;
+ updateProjectionMetadata(output, state, sourceTruncated);
+ if (projectionFits(output)) {
+ best = candidate;
+ low = midpoint + 1;
+ } else {
+ high = midpoint - 1;
+ }
+ }
+ if (best === undefined) {
+ if (!valueTruncationAlreadyCounted) state.truncatedFields--;
+ return undefined;
+ }
+ entry.value = best;
+ return best;
+}
+
+function baseProjection(snapshot: object, state: ProjectionState): PersistentStoreProjection {
+ const output: PersistentStoreProjection = {
+ projection: {
+ entriesOmitted: 0,
+ entryLimit: MAX_PROVIDER_ENTRIES,
+ invalidFields: 0,
+ maxDocumentBytes: MAX_PROVIDER_DOCUMENT_BYTES,
+ returnedEntries: 0,
+ returnedStores: 0,
+ sourceEntries: 0,
+ sourceStores: 0,
+ storeLimit: MAX_PROVIDER_STORES,
+ storesOmitted: 0,
+ truncated: false,
+ truncatedFields: 0,
+ },
+ stores: [],
+ truncated: false,
+ };
+ const outputRecord = output as unknown as Record;
+ const diagnostics = objectProperty(snapshot, 'diagnostics', state);
+ if (diagnostics !== undefined) output.diagnostics = diagnosticsRecord(diagnostics, state);
+ copyOptionalNumber(outputRecord, snapshot, 'inspectedStorageKeys', state);
+ const limits = objectProperty(snapshot, 'limits', state);
+ if (limits !== undefined) {
+ output.limits = knownNumberRecord(
+ limits,
+ [
+ 'maxEntriesPerStore',
+ 'maxErrorCharacters',
+ 'maxInspectedEntriesPerStore',
+ 'maxInspectedStorageKeys',
+ 'maxKeyCharacters',
+ 'maxNameCharacters',
+ 'maxSerializedStoreCharacters',
+ 'maxStorageKeyCharacters',
+ 'maxStores',
+ 'maxTotalBytes',
+ 'maxTotalCharacters',
+ 'maxValueCharacters',
+ ],
+ state,
+ );
+ }
+ copyOptionalNumber(outputRecord, snapshot, 'rejectedStorageKeys', state);
+ copyOptionalString(outputRecord, snapshot, 'storageError', MAX_ERROR_CHARACTERS, state);
+ copyOptionalNumber(outputRecord, snapshot, 'storageErrorLength', state);
+ copyOptionalBoolean(outputRecord, snapshot, 'storageErrorTruncated', state);
+ copyOptionalBoolean(outputRecord, snapshot, 'storageInspectionTruncated', state);
+ const usage = objectProperty(snapshot, 'usage', state);
+ if (usage !== undefined) output.usage = knownNumberRecord(usage, ['bytes', 'characters'], state);
+ return output;
+}
+
+function projectPersistentStoreSnapshot(snapshotValue: unknown): PersistentStoreProjection {
+ const state: ProjectionState = {
+ invalidFields: 0,
+ outputEntries: 0,
+ sourceEntries: 0,
+ sourceEntryCountIncomplete: false,
+ sourceStores: 0,
+ stoppedForBudget: false,
+ truncatedFields: 0,
+ };
+ const snapshot =
+ typeof snapshotValue === 'object' && snapshotValue !== null
+ ? snapshotValue
+ : (Object.create(null) as Record);
+ if (snapshot !== snapshotValue) state.invalidFields++;
+ const sourceTruncated = booleanProperty(snapshot, 'truncated', state) ?? false;
+ const stores = sourceStores(snapshot, state);
+ const output = baseProjection(snapshot, state);
+ updateProjectionMetadata(output, state, sourceTruncated);
+
+ outer: for (const sourceStore of stores) {
+ const store = projectedStore(sourceStore.store, state);
+ const inspectedEntries = Math.min(sourceStore.entryCount, MAX_PROVIDER_ENTRIES_PER_STORE);
+ if (sourceStore.entryCount > inspectedEntries || state.outputEntries + inspectedEntries > MAX_PROVIDER_ENTRIES) {
+ store.entriesTruncated = true;
+ }
+ output.stores.push(store);
+ updateProjectionMetadata(output, state, sourceTruncated);
+ if (!projectionFits(output)) {
+ output.stores.pop();
+ state.stoppedForBudget = true;
+ break;
+ }
+ if (sourceStore.entries === undefined) continue;
+ const entries = store.entries as Record[];
+ for (let index = 0; index < inspectedEntries; index++) {
+ if (state.outputEntries >= MAX_PROVIDER_ENTRIES) break outer;
+ const projected = projectedEntry(safeArrayItem(sourceStore.entries, index, state), state);
+ const entry = projected.output;
+ entries.push(entry);
+ state.outputEntries++;
+ updateProjectionMetadata(output, state, sourceTruncated);
+ if (projectionFits(output)) continue;
+
+ const originalValue = typeof entry.value === 'string' ? entry.value : '';
+ if (index + 1 < sourceStore.entryCount) store.entriesTruncated = true;
+ if (
+ largestFittingValuePrefix(
+ output,
+ entry,
+ originalValue,
+ projected.valueTruncatedByCharacterLimit,
+ state,
+ sourceTruncated,
+ ) !== undefined
+ ) {
+ state.stoppedForBudget = true;
+ break outer;
+ }
+ entries.pop();
+ state.outputEntries--;
+ store.entriesTruncated = true;
+ state.stoppedForBudget = true;
+ updateProjectionMetadata(output, state, sourceTruncated);
+ if (!projectionFits(output)) {
+ state.outputEntries -= entries.length;
+ output.stores.pop();
+ }
+ break outer;
+ }
+ }
+ updateProjectionMetadata(output, state, sourceTruncated);
+ return output;
+}
+
+/** @internal Produces the pre-serialized, provider-parser-compatible read-only Storage result. */
+export function createPersistentStoreDebuggerProviderResult(snapshot: unknown): DebuggerProviderResult {
+ const output = projectPersistentStoreSnapshot(snapshot);
+ const json = JSON.stringify(output);
+ if (utf8ByteLength(json) > MAX_PROVIDER_DOCUMENT_BYTES) {
+ throw new Error('PersistentStore debugger projection exceeded its 43 KiB budget');
+ }
+ return createDebuggerProviderResult(json);
+}
+
+function snapshotReader(nativeModule: unknown): (() => unknown) | undefined {
+ const property = ownDataProperty(nativeModule, 'getPersistentStoreSnapshot');
+ if (!property.valid || typeof property.value !== 'function') return undefined;
+ const reader = property.value as () => unknown;
+ return () => reader.call(nativeModule);
+}
+
+/** @internal Creates the adapter separately from ownership so its behavior can be regression-tested. */
+export function createPersistentStoreDebuggerProvider(nativeModule: unknown): DebuggerProvider {
+ const readSnapshot = snapshotReader(nativeModule);
+ return {
+ availability: () =>
+ readSnapshot === undefined
+ ? { available: false, message: 'PersistentStore inspection is unavailable on this platform.' }
+ : true,
+ description: 'Bounded, read-only PersistentStore snapshots.',
+ handleRequest: (request: DebuggerProviderRequest): DebuggerProviderResult => {
+ if (request.action !== 'snapshot') {
+ throw new Error(`Unsupported PersistentStore debugger action: ${request.action}`);
+ }
+ if (readSnapshot === undefined) {
+ throw new Error('PersistentStore inspection is unavailable on this platform.');
+ }
+ return createPersistentStoreDebuggerProviderResult(readSnapshot());
+ },
+ id: PERSISTENT_STORE_PROVIDER_ID,
+ kind: DebuggerProviderKind.Storage,
+ label: 'PersistentStore',
+ };
+}
+
+/** @internal Registers the adapter with module-owned native and pathless-web reload cleanup. */
+export function registerPersistentStoreDebuggerProvider(
+ ownerModule: DebuggerProviderModule,
+ nativeModule: unknown,
+): DebuggerProviderOwner {
+ const owner = createDebuggerProviderOwner(ownerModule, PERSISTENT_STORE_PROVIDER_OWNER_KEY);
+ owner.register(createPersistentStoreDebuggerProvider(nativeModule));
+ return owner;
+}
+
+registerPersistentStoreDebuggerProvider(module, require('PersistentStoreNative'));
diff --git a/src/valdi_modules/src/valdi/persistence/test/PersistentStoreDebuggerProvider.spec.ts b/src/valdi_modules/src/valdi/persistence/test/PersistentStoreDebuggerProvider.spec.ts
new file mode 100644
index 000000000..3065fda2d
--- /dev/null
+++ b/src/valdi_modules/src/valdi/persistence/test/PersistentStoreDebuggerProvider.spec.ts
@@ -0,0 +1,221 @@
+import 'jasmine/src/jasmine';
+import { getModuleLoader } from 'valdi_core/src/ModuleLoaderGlobal';
+import { DebuggerProviderKind } from 'valdi_core/src/debugging/DebuggerProvider';
+import {
+ createPersistentStoreDebuggerProvider,
+ createPersistentStoreDebuggerProviderResult,
+ registerPersistentStoreDebuggerProvider,
+} from '../src/PersistentStoreDebuggerProvider';
+
+interface ProjectedEntry {
+ readonly encoding: number | string;
+ readonly key: string;
+ readonly value: string;
+ readonly valueLength?: number;
+ readonly valueTruncated?: boolean;
+}
+
+interface ProjectedStore {
+ readonly backend: string;
+ readonly entries: readonly ProjectedEntry[];
+ readonly entriesTruncated?: boolean;
+ readonly inspectionTruncated?: boolean;
+}
+
+interface ProjectionMetadata {
+ readonly entriesOmitted: number;
+ readonly invalidFields: number;
+ readonly returnedEntries: number;
+ readonly truncated: boolean;
+ readonly truncatedFields: number;
+}
+
+interface ProjectedSnapshot {
+ readonly projection: ProjectionMetadata;
+ readonly storageError?: string;
+ readonly storageInspectionTruncated?: boolean;
+ readonly stores: readonly ProjectedStore[];
+ readonly truncated: boolean;
+}
+
+function resultData(snapshot: unknown): ProjectedSnapshot {
+ return JSON.parse(createPersistentStoreDebuggerProviderResult(snapshot).json) as ProjectedSnapshot;
+}
+
+function utf8ByteLength(value: string): number {
+ let bytes = 0;
+ for (let index = 0; index < value.length; index++) {
+ const code = value.charCodeAt(index);
+ if (code <= 0x7f) bytes++;
+ else if (code <= 0x7ff) bytes += 2;
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
+ const next = value.charCodeAt(index + 1);
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ bytes += 4;
+ index++;
+ } else bytes += 3;
+ } else bytes += 3;
+ }
+ return bytes;
+}
+
+describe('PersistentStoreDebuggerProvider', () => {
+ it('projects a parser-compatible snapshot within global entry and UTF-8 budgets', () => {
+ const stores = Array.from({ length: 6 }, (_, storeIndex) => ({
+ backend: 'memory',
+ entries: Array.from({ length: 100 }, (_, entryIndex) => ({
+ encoding: 0,
+ key: `${storeIndex.toString()}-${entryIndex.toString()}`,
+ value: 'value',
+ })),
+ inspectedEntries: 100,
+ name: `store-${storeIndex.toString()}`,
+ }));
+ const result = createPersistentStoreDebuggerProviderResult({ stores, truncated: false });
+ const data = JSON.parse(result.json) as ProjectedSnapshot;
+ const returnedEntries = data.stores.reduce((count, store) => count + store.entries.length, 0);
+
+ expect(utf8ByteLength(result.json)).toBeLessThanOrEqual(43 * 1024);
+ expect(returnedEntries).toBeLessThanOrEqual(500);
+ expect(data.projection.returnedEntries).toBe(returnedEntries);
+ expect(data.projection.entriesOmitted).toBe(600 - returnedEntries);
+ expect(data.projection.truncated).toBeTrue();
+ expect(data.stores.every(store => store.entries.length <= 100)).toBeTrue();
+ expect(data.stores.some(store => store.entriesTruncated === true)).toBeTrue();
+ });
+
+ it('truncates Unicode values without splitting a surrogate pair', () => {
+ const value = '🙂'.repeat(20_000);
+ const data = resultData({
+ stores: [{ backend: 'memory', entries: [{ encoding: 0, key: 'emoji', value }], name: 'unicode' }],
+ truncated: false,
+ });
+ const projected = data.stores[0].entries[0];
+ const finalCodeUnit = projected.value.charCodeAt(projected.value.length - 1);
+
+ expect(projected.valueTruncated).toBeTrue();
+ expect(projected.valueLength).toBe(value.length);
+ expect(projected.value.length).toBeLessThan(value.length);
+ expect(data.projection.truncatedFields).toBe(1);
+ expect(finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff).toBeFalse();
+ expect(utf8ByteLength(JSON.stringify(data))).toBeLessThanOrEqual(43 * 1024);
+ });
+
+ it('budgets lone surrogates by their escaped JSON byte cost', () => {
+ const value = '\ud800'.repeat(10_000);
+ const result = createPersistentStoreDebuggerProviderResult({
+ stores: [
+ {
+ backend: 'memory',
+ entries: [
+ { encoding: 0, key: 'surrogates', value },
+ { encoding: 0, key: 'later', value: 'later' },
+ ],
+ name: 'unicode',
+ },
+ ],
+ truncated: false,
+ });
+ const data = JSON.parse(result.json) as ProjectedSnapshot;
+ const projected = data.stores[0].entries[0];
+
+ expect(utf8ByteLength(result.json)).toBeLessThanOrEqual(43 * 1024);
+ expect(projected.valueTruncated).toBeTrue();
+ expect(projected.valueLength).toBe(value.length);
+ expect(projected.value.length).toBeLessThan(value.length);
+ expect(data.projection.truncatedFields).toBe(1);
+ expect(data.projection.entriesOmitted).toBe(1);
+ expect(data.stores[0].entriesTruncated).toBeTrue();
+ });
+
+ it('preserves errors, truncation metadata, and numeric encoding zero', () => {
+ const data = resultData({
+ storageError: 'denied',
+ storageInspectionTruncated: true,
+ stores: [
+ {
+ backend: 'browser',
+ entries: [{ encoding: 0, key: 'theme', value: 'dark' }],
+ inspectedEntries: 1,
+ inspectionTruncated: true,
+ name: 'preferences',
+ },
+ ],
+ truncated: true,
+ });
+
+ expect(data.storageError).toBe('denied');
+ expect(data.storageInspectionTruncated).toBeTrue();
+ expect(data.stores[0].backend).toBe('browser');
+ expect(data.stores[0].inspectionTruncated).toBeTrue();
+ expect(data.stores[0].entries[0].encoding).toBe(0);
+ expect(data.truncated).toBeTrue();
+ });
+
+ it('reads only known own data properties without invoking getters or enumeration traps', () => {
+ let getterCalls = 0;
+ let ownKeysCalls = 0;
+ const entry = Object.defineProperty({ encoding: 0, key: 'safe' }, 'value', {
+ enumerable: true,
+ get: () => {
+ getterCalls++;
+ return 'secret';
+ },
+ });
+ const snapshot = new Proxy(
+ { stores: [{ backend: 'memory', entries: [entry], name: 'safe' }], truncated: false },
+ {
+ ownKeys: value => {
+ ownKeysCalls++;
+ return Reflect.ownKeys(value);
+ },
+ },
+ );
+ const data = resultData(snapshot);
+
+ expect(getterCalls).toBe(0);
+ expect(ownKeysCalls).toBe(0);
+ expect(data.stores[0].entries[0].value).toBe('');
+ expect(data.projection.invalidFields).toBeGreaterThan(0);
+ });
+
+ it('exposes only snapshot and reports unsupported native inspectors as unavailable', async () => {
+ const available = createPersistentStoreDebuggerProvider({
+ getPersistentStoreSnapshot: () => ({ stores: [], truncated: false }),
+ });
+ const unavailable = createPersistentStoreDebuggerProvider({});
+
+ expect(available.id).toBe('persistent-store');
+ expect(available.kind).toBe(DebuggerProviderKind.Storage);
+ expect(available.availability!()).toBeTrue();
+ const result = await available.handleRequest({ action: 'snapshot' });
+ expect((JSON.parse(result.json) as ProjectedSnapshot).stores).toEqual([]);
+ expect(() => available.handleRequest({ action: 'clear' })).toThrowError(/Unsupported PersistentStore/);
+ expect(unavailable.availability!()).toEqual(jasmine.objectContaining({ available: false }));
+ expect(() => unavailable.handleRequest({ action: 'snapshot' })).toThrowError(/unavailable/);
+ });
+
+ it('binds pathless web and native registrations to the provider owner lifecycle', () => {
+ const callbacks = new Map void>();
+ const observedModules = new Map();
+ spyOn(getModuleLoader(), 'onHotReload').and.callFake((ownerModule, path, callback) => {
+ callbacks.set(path, callback);
+ observedModules.set(path, ownerModule);
+ return () => callbacks.delete(path);
+ });
+ const pathlessModule: { path?: string } = {};
+ const webOwner = registerPersistentStoreDebuggerProvider(pathlessModule, {
+ getPersistentStoreSnapshot: () => ({ stores: [], truncated: false }),
+ });
+
+ expect(observedModules.get('persistence/src/PersistentStoreDebuggerProvider')).toBe(pathlessModule);
+ callbacks.get('persistence/src/PersistentStoreDebuggerProvider')!();
+ expect(() => webOwner.register(createPersistentStoreDebuggerProvider({}))).toThrowError(/owner is disposed/);
+
+ const nativeModule = { path: 'persistence/native/PersistentStoreDebuggerProvider' };
+ const nativeOwner = registerPersistentStoreDebuggerProvider(nativeModule, {});
+ expect(observedModules.get(nativeModule.path)).toBe(nativeModule);
+ callbacks.get(nativeModule.path)!();
+ expect(() => nativeOwner.register(createPersistentStoreDebuggerProvider({}))).toThrowError(/owner is disposed/);
+ });
+});