/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { StorageError } from "../StorageDriver.js"; import { type SupportedStorageTypes, fromJson, toJson } from "../StringifyTools.js"; export type StoreData = Record>; /** * A single operation within a WAL commit. */ export type WalOp = | { op: "upd"; key: string; values: Record } | { op: "del"; key: string; values?: string[] }; /** * A commit is a timestamped array of operations serialized as one WAL line. */ export interface WalCommit { /** * Millis since Unix epoch when the commit was written. */ ts: number; /** * The operations in this commit. */ ops: WalOp[]; } /** * 48-bit commit ID: high 32 bits = segment number, low 16 bits = line offset. */ export interface WalCommitId { segment: number; offset: number; } /** Maximum line offset within a single segment (16-bit). */ export const MAX_SEGMENT_LINES = 0xffff; /** * Encode context array to a `/`-delimited key, URL-encoding `%` and `/` in each segment. */ export function encodeContextKey(contexts: string[]): string { return contexts.map(s => s.replace(/%/g, "%25").replace(/\//g, "%2F")).join("/"); } /** * Decode a `/`-delimited context key back to a context array. */ export function decodeContextKey(key: string): string[] { return key.split("/").map(s => s.replace(/%2F/gi, "/").replace(/%25/g, "%")); } /** * Serialize a commit to a JSON line for the WAL. */ export function serializeCommit(commit: WalCommit): string { return toJson({ ts: commit.ts, ops: commit.ops } as unknown as SupportedStorageTypes); } /** * Deserialize a JSON line back to a commit. * * Handles legacy bare-array format by wrapping as `{ ts: 0, ops }`. * * Throws if the parsed value is not a structurally valid commit so callers * (e.g. {@link WalReader}) can skip the line rather than yielding a commit * that crashes replay downstream. */ export function deserializeCommit(line: string): WalCommit { const parsed: unknown = fromJson(line); if (Array.isArray(parsed)) { parsed.forEach(validateOp); return { ts: 0, ops: parsed as WalOp[] }; } if (!isPlainRecord(parsed)) { throw new StorageError("WAL commit is not an object"); } const { ts, ops } = parsed; if (typeof ts !== "number") { throw new StorageError("WAL commit `ts` is missing or not a number"); } if (!Array.isArray(ops)) { throw new StorageError("WAL commit `ops` is missing or not an array"); } ops.forEach(validateOp); return { ts, ops: ops as WalOp[] }; } function isPlainRecord(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } function validateOp(op: unknown, index: number): void { if (!isPlainRecord(op)) { throw new StorageError(`WAL op[${index}] is not an object`); } const { op: kind, key, values } = op; if (typeof key !== "string") { throw new StorageError(`WAL op[${index}] \`key\` is not a string`); } if (kind === "upd") { if (!isPlainRecord(values)) { throw new StorageError(`WAL op[${index}] upd \`values\` is not an object`); } return; } if (kind === "del") { if (values === undefined) { return; } if (!Array.isArray(values) || !values.every(v => typeof v === "string")) { throw new StorageError(`WAL op[${index}] del \`values\` is not a string array`); } return; } throw new StorageError(`WAL op[${index}] has unknown op kind \`${String(kind)}\``); } /** * Format a segment number as an 8-digit hex filename with `.jsonl` extension. */ export function segmentFilename(segment: number): string { return segment.toString(16).padStart(8, "0") + ".jsonl"; } /** * Format a segment number as a compressed segment filename. */ export function compressedSegmentFilename(segment: number): string { return segment.toString(16).padStart(8, "0") + ".jsonl.gz"; } /** * Returns true if the filename is a gzip-compressed segment file. */ export function isCompressedSegmentFile(name: string): boolean { return /^[0-9a-f]{8}\.jsonl\.gz$/i.test(name); } /** * Parse a segment number from a filename. Returns undefined if not a valid segment file. */ export function parseSegmentFilename(name: string): number | undefined { const match = name.match(/^([0-9a-f]{8})\.jsonl(\.gz)?$/i); if (!match) { return undefined; } return parseInt(match[1], 16); } /** * Combine a commit ID into a single number (48-bit safe in JS). */ export function commitIdToNumber(id: WalCommitId): number { return id.segment * 0x10000 + id.offset; } /** * Compare two commit IDs. Returns negative if a < b, 0 if equal, positive if a > b. */ export function compareCommitIds(a: WalCommitId, b: WalCommitId): number { if (a.segment !== b.segment) { return a.segment - b.segment; } return a.offset - b.offset; }