277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import type { NvsEntry, NvsPartition, NvsFlashStats } from './types';
|
|
import { NvsType, NvsVersion, isPrimitiveType } from './types';
|
|
import { ENTRIES_PER_PAGE, ENTRY_SIZE, PAGE_SIZE, MAX_KEY_LENGTH, MAX_NAMESPACES, MAX_STRING_LENGTH, MAX_BLOB_SIZE_V1, MAX_BLOB_SIZE_V2 } from './constants';
|
|
|
|
/** Generate a random unique ID for client-side entry tracking */
|
|
export function generateEntryId(): string {
|
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
}
|
|
|
|
/** Create an empty partition with default V2 version */
|
|
export function createEmptyPartition(version: NvsVersion = NvsVersion.V2): NvsPartition {
|
|
return { entries: [], namespaces: [], version };
|
|
}
|
|
|
|
/** Add a new entry. Returns a new NvsPartition (immutable). */
|
|
export function addEntry(
|
|
partition: NvsPartition,
|
|
entry: Omit<NvsEntry, 'id'>,
|
|
): NvsPartition {
|
|
const newEntry: NvsEntry = { ...entry, id: generateEntryId() };
|
|
const namespaces = partition.namespaces.includes(entry.namespace)
|
|
? partition.namespaces
|
|
: [...partition.namespaces, entry.namespace];
|
|
return {
|
|
...partition,
|
|
entries: [...partition.entries, newEntry],
|
|
namespaces,
|
|
};
|
|
}
|
|
|
|
/** Remove an entry by ID. Returns a new NvsPartition. */
|
|
export function removeEntry(partition: NvsPartition, entryId: string): NvsPartition {
|
|
const entries = partition.entries.filter(e => e.id !== entryId);
|
|
// Clean up namespaces that have no remaining entries
|
|
const usedNs = new Set(entries.map(e => e.namespace));
|
|
const namespaces = partition.namespaces.filter(ns => usedNs.has(ns));
|
|
return { ...partition, entries, namespaces };
|
|
}
|
|
|
|
/** Update an existing entry. Returns a new NvsPartition. */
|
|
export function updateEntry(
|
|
partition: NvsPartition,
|
|
entryId: string,
|
|
updates: Partial<Omit<NvsEntry, 'id'>>,
|
|
): NvsPartition {
|
|
const entries = partition.entries.map(e =>
|
|
e.id === entryId ? { ...e, ...updates } : e,
|
|
);
|
|
// If namespace changed, ensure it's in the list
|
|
let namespaces = partition.namespaces;
|
|
if (updates.namespace && !namespaces.includes(updates.namespace)) {
|
|
namespaces = [...namespaces, updates.namespace];
|
|
}
|
|
// Clean up unused namespaces
|
|
const usedNs = new Set(entries.map(e => e.namespace));
|
|
namespaces = namespaces.filter(ns => usedNs.has(ns));
|
|
return { ...partition, entries, namespaces };
|
|
}
|
|
|
|
/** Duplicate an entry with a new ID. */
|
|
export function duplicateEntry(partition: NvsPartition, entryId: string): NvsPartition {
|
|
const source = partition.entries.find(e => e.id === entryId);
|
|
if (!source) return partition;
|
|
const clone: NvsEntry = {
|
|
...source,
|
|
id: generateEntryId(),
|
|
key: source.key,
|
|
// Deep copy Uint8Array values
|
|
value: source.value instanceof Uint8Array
|
|
? new Uint8Array(source.value)
|
|
: source.value,
|
|
};
|
|
return { ...partition, entries: [...partition.entries, clone] };
|
|
}
|
|
|
|
/**
|
|
* Merge source into target by (namespace, key) match.
|
|
* @param mode 'overwrite' replaces matching entries; 'skip' keeps target's value
|
|
*/
|
|
export function mergePartitions(
|
|
target: NvsPartition,
|
|
source: NvsPartition,
|
|
mode: 'overwrite' | 'skip' = 'overwrite',
|
|
): NvsPartition {
|
|
const entries = [...target.entries];
|
|
const namespaces = [...target.namespaces];
|
|
|
|
for (const srcEntry of source.entries) {
|
|
if (!namespaces.includes(srcEntry.namespace)) {
|
|
namespaces.push(srcEntry.namespace);
|
|
}
|
|
const idx = entries.findIndex(
|
|
e => e.namespace === srcEntry.namespace && e.key === srcEntry.key,
|
|
);
|
|
if (idx >= 0) {
|
|
if (mode === 'overwrite') {
|
|
entries[idx] = { ...srcEntry, id: entries[idx].id };
|
|
}
|
|
// skip: do nothing
|
|
} else {
|
|
entries.push({ ...srcEntry, id: generateEntryId() });
|
|
}
|
|
}
|
|
|
|
return { ...target, entries, namespaces };
|
|
}
|
|
|
|
/** Calculate the entry span for a single NvsEntry */
|
|
export function entrySpan(entry: NvsEntry, version: NvsVersion): number {
|
|
if (isPrimitiveType(entry.type)) return 1;
|
|
|
|
if (entry.type === NvsType.SZ) {
|
|
const strBytes = new TextEncoder().encode(entry.value as string);
|
|
const dataLen = strBytes.length + 1; // +1 for null terminator
|
|
return 1 + Math.ceil(dataLen / ENTRY_SIZE);
|
|
}
|
|
|
|
// BLOB / BLOB_DATA
|
|
const data = entry.value as Uint8Array;
|
|
if (version === NvsVersion.V1 || entry.type === NvsType.BLOB) {
|
|
return 1 + Math.ceil(data.length / ENTRY_SIZE);
|
|
}
|
|
|
|
// V2: BLOB_DATA chunks + BLOB_IDX — each chunk has its own header entry
|
|
const maxChunkPayload = (ENTRIES_PER_PAGE - 1) * ENTRY_SIZE;
|
|
const chunkCount = Math.max(1, Math.ceil(data.length / maxChunkPayload));
|
|
let totalSpan = 0;
|
|
let remaining = data.length;
|
|
for (let i = 0; i < chunkCount; i++) {
|
|
const chunkLen = Math.min(remaining, maxChunkPayload);
|
|
totalSpan += 1 + Math.ceil(chunkLen / ENTRY_SIZE);
|
|
remaining -= chunkLen;
|
|
}
|
|
return totalSpan + 1; // +1 for BLOB_IDX entry
|
|
}
|
|
|
|
/** Calculate flash usage statistics for a given partition at a target size */
|
|
export function calculateFlashStats(
|
|
partition: NvsPartition,
|
|
targetSizeBytes: number,
|
|
): NvsFlashStats {
|
|
const totalPages = Math.floor(targetSizeBytes / PAGE_SIZE);
|
|
const usablePages = Math.max(totalPages - 1, 0); // reserve 1 for GC
|
|
const maxEntries = usablePages * ENTRIES_PER_PAGE;
|
|
|
|
// Build a flat list of entry spans (namespace defs + data entries)
|
|
const spans: number[] = [];
|
|
for (const _ns of partition.namespaces) spans.push(1);
|
|
for (const entry of partition.entries) spans.push(entrySpan(entry, partition.version));
|
|
|
|
// Simulate page-packing to count actual slot consumption (including fragmentation waste).
|
|
// Entries cannot span page boundaries; remaining slots on a page are wasted when an entry
|
|
// doesn't fit, identical to the serializer's behaviour.
|
|
let currentEntryIdx = 0;
|
|
let totalSlotsUsed = 0;
|
|
for (const span of spans) {
|
|
if (currentEntryIdx + span > ENTRIES_PER_PAGE) {
|
|
totalSlotsUsed += ENTRIES_PER_PAGE - currentEntryIdx; // wasted slots
|
|
currentEntryIdx = 0;
|
|
}
|
|
totalSlotsUsed += span;
|
|
currentEntryIdx += span;
|
|
if (currentEntryIdx >= ENTRIES_PER_PAGE) currentEntryIdx = 0;
|
|
}
|
|
|
|
const logicalEntries = spans.reduce((a, b) => a + b, 0);
|
|
const usedBytes = totalSlotsUsed * ENTRY_SIZE + totalPages * 64; // 64 = header + bitmap
|
|
const usagePercent = maxEntries > 0 ? Math.min((totalSlotsUsed / maxEntries) * 100, 100) : 0;
|
|
|
|
return {
|
|
totalBytes: targetSizeBytes,
|
|
totalPages,
|
|
usedEntries: logicalEntries,
|
|
maxEntries,
|
|
usedBytes,
|
|
usagePercent: Math.round(usagePercent * 10) / 10,
|
|
};
|
|
}
|
|
|
|
/** Validate partition data. Returns array of error messages (empty = valid). */
|
|
export function validatePartition(partition: NvsPartition): string[] {
|
|
const errors: string[] = [];
|
|
|
|
if (partition.namespaces.length > MAX_NAMESPACES) {
|
|
errors.push(`Namespace count exceeds limit ${MAX_NAMESPACES}`);
|
|
}
|
|
|
|
for (const ns of partition.namespaces) {
|
|
if (ns.length === 0) {
|
|
errors.push('Namespace name cannot be empty');
|
|
}
|
|
if (ns.length > MAX_KEY_LENGTH) {
|
|
errors.push(`Namespace "${ns}" exceeds ${MAX_KEY_LENGTH} characters`);
|
|
}
|
|
}
|
|
|
|
for (const entry of partition.entries) {
|
|
if (entry.key.length === 0) {
|
|
errors.push(`Empty key in namespace "${entry.namespace}"`);
|
|
}
|
|
if (entry.key.length > MAX_KEY_LENGTH) {
|
|
errors.push(`Key "${entry.key}" exceeds ${MAX_KEY_LENGTH} characters`);
|
|
}
|
|
if (!partition.namespaces.includes(entry.namespace)) {
|
|
errors.push(`Key "${entry.key}" references unregistered namespace "${entry.namespace}"`);
|
|
}
|
|
|
|
// Validate value ranges for primitive types
|
|
if (isPrimitiveType(entry.type)) {
|
|
if (typeof entry.value === 'number') {
|
|
const v = entry.value;
|
|
switch (entry.type) {
|
|
case NvsType.U8: if (v < 0 || v > 0xFF) errors.push(`"${entry.key}" U8 value out of range`); break;
|
|
case NvsType.I8: if (v < -128 || v > 127) errors.push(`"${entry.key}" I8 value out of range`); break;
|
|
case NvsType.U16: if (v < 0 || v > 0xFFFF) errors.push(`"${entry.key}" U16 value out of range`); break;
|
|
case NvsType.I16: if (v < -32768 || v > 32767) errors.push(`"${entry.key}" I16 value out of range`); break;
|
|
case NvsType.U32: if (v < 0 || v > 0xFFFFFFFF) errors.push(`"${entry.key}" U32 value out of range`); break;
|
|
case NvsType.I32: if (v < -2147483648 || v > 2147483647) errors.push(`"${entry.key}" I32 value out of range`); break;
|
|
}
|
|
} else if (typeof entry.value === 'bigint') {
|
|
const v = entry.value;
|
|
switch (entry.type) {
|
|
case NvsType.U64:
|
|
if (v < 0n || v > 0xFFFFFFFFFFFFFFFFn) errors.push(`"${entry.key}" U64 value out of range`);
|
|
break;
|
|
case NvsType.I64:
|
|
if (v < -9223372036854775808n || v > 9223372036854775807n) errors.push(`"${entry.key}" I64 value out of range`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate string length
|
|
if (entry.type === NvsType.SZ && typeof entry.value === 'string') {
|
|
const byteLen = new TextEncoder().encode(entry.value).length;
|
|
if (byteLen >= MAX_STRING_LENGTH) {
|
|
errors.push(`"${entry.key}" string length ${byteLen} bytes exceeds limit ${MAX_STRING_LENGTH - 1}`);
|
|
}
|
|
}
|
|
|
|
// Validate blob size
|
|
// NvsType.BLOB uses the legacy V1 single-page format regardless of partition version,
|
|
// so it is always capped at MAX_BLOB_SIZE_V1.
|
|
// NvsType.BLOB_DATA uses the V2 chunked format and is capped at MAX_BLOB_SIZE_V2.
|
|
if (entry.type === NvsType.BLOB && entry.value instanceof Uint8Array) {
|
|
if (entry.value.length > MAX_BLOB_SIZE_V1) {
|
|
errors.push(`"${entry.key}" BLOB ${entry.value.length} bytes exceeds limit ${MAX_BLOB_SIZE_V1}`);
|
|
}
|
|
} else if (entry.type === NvsType.BLOB_DATA && entry.value instanceof Uint8Array) {
|
|
if (entry.value.length > MAX_BLOB_SIZE_V2) {
|
|
errors.push(`"${entry.key}" BLOB ${entry.value.length} bytes exceeds V2 limit ${MAX_BLOB_SIZE_V2}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for duplicate (namespace, key) pairs
|
|
const seen = new Set<string>();
|
|
for (const entry of partition.entries) {
|
|
const k = `${entry.namespace}::${entry.key}`;
|
|
if (seen.has(k)) {
|
|
errors.push(`Duplicate key: ${entry.namespace}/${entry.key}`);
|
|
}
|
|
seen.add(k);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/** Sort entries by namespace, then by key */
|
|
export function sortEntries(partition: NvsPartition): NvsPartition {
|
|
const entries = [...partition.entries].sort((a, b) => {
|
|
const nsCmp = a.namespace.localeCompare(b.namespace);
|
|
return nsCmp !== 0 ? nsCmp : a.key.localeCompare(b.key);
|
|
});
|
|
return { ...partition, entries };
|
|
}
|