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/); + }); +});