feat: migrate var/tag store from JSONL to better-sqlite3 #68

Merged
xiaomo merged 2 commits from feat/60-sqlite-var-tag into main 2026-06-03 08:40:46 +00:00
8 changed files with 970 additions and 452 deletions
+4 -2
View File
@@ -15,8 +15,9 @@
"src"
],
"dependencies": {
"cborg": "^4.2.3",
"@ocas/core": "workspace:*"
"@ocas/core": "workspace:*",
"better-sqlite3": "^12.10.0",
"cborg": "^4.2.3"
},
"repository": {
"type": "git",
@@ -28,6 +29,7 @@
"url": "https://github.com/shazhou-ww/ocas/issues"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.9.1"
}
}
+1
View File
@@ -1 +1,2 @@
export { createFsStore, openStore, prepareStore } from "./store.js";
export { createSqliteVarStore } from "./sqlite-store.js";
+681
View File
@@ -0,0 +1,681 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import Database from "better-sqlite3";
import type {
CasStore,
Hash,
HistoryEntry,
ListEntry,
ListOptions,
Tag,
TagOp,
TagStore,
Variable,
VarListOptions,
VarSetOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
checkTagLabelConflict,
extractSchema,
MAX_HISTORY,
pushHistory,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
cloneVarRecord,
} from "@ocas/core";
const DB_FILE = "_store.db";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
function openDb(dir: string): Database.Database {
mkdirSync(dir, { recursive: true });
const db = new Database(join(dir, DB_FILE));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
return db;
}
function initVarTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS vars (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
labels TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (name, schema)
);
CREATE TABLE IF NOT EXISTS var_history (
name TEXT NOT NULL,
schema TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL,
set_at INTEGER NOT NULL,
PRIMARY KEY (name, schema, position),
FOREIGN KEY (name, schema) REFERENCES vars(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vars_name ON vars(name);
CREATE INDEX IF NOT EXISTS idx_vars_created ON vars(created);
CREATE INDEX IF NOT EXISTS idx_vars_updated ON vars(updated);
CREATE INDEX IF NOT EXISTS idx_var_history_pos_desc ON var_history(name, schema, position DESC);
`);
}
function initTagTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
target TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
created INTEGER NOT NULL,
PRIMARY KEY (target, key)
);
CREATE INDEX IF NOT EXISTS idx_tags_key ON tags(key);
CREATE INDEX IF NOT EXISTS idx_tags_key_value ON tags(key, value);
`);
}
// ── JSONL migration ──
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
function migrateJsonlVars(
db: Database.Database,
dir: string,
cas: CasStore,
): void {
const path = join(dir, VARS_FILE);
if (!existsSync(path)) return;
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
if (records.size === 0) return;
const insertVar = db.prepare(`
INSERT OR REPLACE INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertHistory = db.prepare(`
INSERT OR REPLACE INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
const migrate = db.transaction(() => {
for (const rec of records.values()) {
insertVar.run(
rec.name,
rec.schema,
rec.value,
rec.created,
rec.updated,
JSON.stringify(rec.tags),
JSON.stringify(rec.labels),
);
for (const h of rec.history) {
insertHistory.run(rec.name, rec.schema, h.value, h.position, h.setAt);
}
}
});
migrate();
}
function migrateJsonlTags(db: Database.Database, dir: string): void {
const path = join(dir, TAGS_FILE);
if (!existsSync(path)) return;
const byTarget = new Map<Hash, Map<string, Tag>>();
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
}
} catch {
// skip
}
}
if (byTarget.size === 0) return;
const insertTag = db.prepare(`
INSERT OR REPLACE INTO tags (target, key, value, created)
VALUES (?, ?, ?, ?)
`);
const migrate = db.transaction(() => {
for (const tm of byTarget.values()) {
for (const tag of tm.values()) {
insertTag.run(tag.target, tag.key, tag.value, tag.created);
}
}
});
migrate();
}
// ── Row helpers ──
function toVariable(row: Record<string, unknown>): Variable {
return {
name: row.name as string,
schema: row.schema as Hash,
value: row.value as Hash,
created: row.created as number,
updated: row.updated as number,
tags: JSON.parse(row.tags as string) as Record<string, string>,
labels: JSON.parse(row.labels as string) as string[],
};
}
function toHistoryEntry(r: Record<string, unknown>): HistoryEntry {
return {
value: r.value as Hash,
position: r.position as number,
setAt: r.set_at as number,
};
}
function toTag(r: Record<string, unknown>, target: Hash): Tag {
return {
key: r.key as string,
value: r.value as string | null,
target,
created: r.created as number,
};
}
// ── Main factory ──
export function createSqliteVarStore(
dir: string,
cas: CasStore,
): { var: VarStore; tag: TagStore; close: () => void } {
const db = openDb(dir);
initVarTables(db);
initTagTables(db);
// Migrate JSONL if present (one-time, idempotent)
migrateJsonlVars(db, dir, cas);
migrateJsonlTags(db, dir);
let closed = false;
// ── Prepared statements (var) ──
const stmtGetVar = db.prepare(
"SELECT * FROM vars WHERE name = ? AND schema = ?",
);
const stmtGetByName = db.prepare("SELECT * FROM vars WHERE name = ?");
const stmtInsertVar = db.prepare(`
INSERT INTO vars (name, schema, value, created, updated, tags, labels)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const stmtUpdateVar = db.prepare(`
UPDATE vars SET value = ?, updated = ?, tags = ?, labels = ?
WHERE name = ? AND schema = ?
`);
const stmtDeleteVar = db.prepare(
"DELETE FROM vars WHERE name = ? AND schema = ?",
);
const stmtDeleteVarByName = db.prepare("DELETE FROM vars WHERE name = ?");
const stmtInsertHistory = db.prepare(`
INSERT INTO var_history (name, schema, value, position, set_at)
VALUES (?, ?, ?, ?, ?)
`);
const stmtGetHistory = db.prepare(
"SELECT value, position, set_at FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC",
);
const stmtMaxPosition = db.prepare(
"SELECT MAX(position) as max_pos FROM var_history WHERE name = ? AND schema = ?",
);
const stmtDeleteOldHistory = db.prepare(
"DELETE FROM var_history WHERE name = ? AND schema = ? AND position NOT IN (SELECT position FROM var_history WHERE name = ? AND schema = ? ORDER BY position DESC LIMIT ?)",
);
// ── Prepared statements (tag) ──
const stmtUpsertTag = db.prepare(`
INSERT INTO tags (target, key, value, created) VALUES (?, ?, ?, ?)
ON CONFLICT(target, key) DO UPDATE SET value = excluded.value
`);
const stmtDeleteTag = db.prepare(
"DELETE FROM tags WHERE target = ? AND key = ?",
);
const stmtGetTagsByTarget = db.prepare(
"SELECT * FROM tags WHERE target = ? ORDER BY key",
);
const stmtGetTagsByKey = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? ORDER BY created ASC",
);
const stmtGetTagsByKeyValue = db.prepare(
"SELECT target, key, value, created FROM tags WHERE key = ? AND value = ? ORDER BY created ASC",
);
// ── Transactional helpers ──
const txnSetVar = db.transaction(
(
name: string,
schema: Hash,
hash: Hash,
now: number,
tagsJson: string,
labelsJson: string,
isNew: boolean,
valueChanged: boolean,
) => {
if (isNew) {
stmtInsertVar.run(name, schema, hash, now, now, tagsJson, labelsJson);
stmtInsertHistory.run(name, schema, hash, 0, now);
} else if (valueChanged) {
const maxRow = stmtMaxPosition.get(name, schema) as {
max_pos: number | null;
};
const nextPos = (maxRow.max_pos ?? -1) + 1;
stmtInsertHistory.run(name, schema, hash, nextPos, now);
stmtDeleteOldHistory.run(name, schema, name, schema, MAX_HISTORY);
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
} else {
stmtUpdateVar.run(hash, now, tagsJson, labelsJson, name, schema);
}
},
);
const txnTagOps = db.transaction(
(target: Hash, operations: TagOp[], now: number) => {
for (const op of operations) {
if (op.op === "set") {
// Use ON CONFLICT to preserve created time — but we need existing created
const existing = db
.prepare("SELECT created FROM tags WHERE target = ? AND key = ?")
.get(target, op.key) as { created: number } | undefined;
const created = existing?.created ?? now;
stmtUpsertTag.run(target, op.key, op.value ?? null, created);
} else {
stmtDeleteTag.run(target, op.key);
}
}
},
);
const txnUntag = db.transaction((target: Hash, keys: string[]) => {
for (const k of keys) {
stmtDeleteTag.run(target, k);
}
});
// ── VarStore implementation ──
const varStore: VarStore = {
set(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const schema = extractSchema(cas, hash);
const existing = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
const now = Date.now();
if (existing) {
const v = toVariable(existing);
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
schema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
}
// New variable
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
txnSetVar(
name,
schema,
hash,
now,
JSON.stringify(tags),
JSON.stringify(labels),
true,
false,
);
return {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
};
},
get(name: string, schema?: Hash): Variable | null {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
return row ? toVariable(row) : null;
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length !== 1) return null;
return toVariable(rows[0]!);
},
remove(name: string, schema?: Hash): Variable[] {
if (schema !== undefined) {
const row = stmtGetVar.get(name, schema) as
| Record<string, unknown>
| undefined;
if (!row) return [];
const v = toVariable(row);
stmtDeleteVar.run(name, schema);
return [v];
}
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) return [];
const removed = rows.map(toVariable);
stmtDeleteVarByName.run(name);
return removed;
},
update(name: string, hash: Hash, options?: VarSetOptions): Variable {
validateName(name);
const newSchema = extractSchema(cas, hash);
const rows = stmtGetByName.all(name) as Record<string, unknown>[];
if (rows.length === 0) throw new VariableNotFoundError(name, newSchema);
const existing = stmtGetVar.get(name, newSchema) as
| Record<string, unknown>
| undefined;
if (!existing) {
const first = toVariable(rows[0]!);
throw new SchemaMismatchError(first.schema, newSchema);
}
const v = toVariable(existing);
const now = Date.now();
const tags = options?.tags ?? v.tags;
const labels = options?.labels ?? v.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const valueChanged = v.value !== hash;
const newTags = options !== undefined ? tags : v.tags;
const newLabels = options !== undefined ? labels : v.labels;
if (valueChanged || options !== undefined) {
txnSetVar(
name,
newSchema,
hash,
valueChanged ? now : v.updated,
JSON.stringify(newTags),
JSON.stringify(newLabels),
false,
valueChanged,
);
}
return {
name,
schema: newSchema,
value: hash,
created: v.created,
updated: valueChanged ? now : v.updated,
tags: { ...newTags },
labels: [...newLabels],
};
},
list(options?: VarListOptions): Variable[] {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const limit = options?.limit;
if (limit !== undefined && limit <= 0) return [];
// Build dynamic query
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.exactName !== undefined) {
conditions.push("name = ?");
params.push(options.exactName);
}
if (options?.namePrefix !== undefined) {
conditions.push("name LIKE ? ESCAPE '\\'");
const escaped = options.namePrefix
.replace(/\\/g, "\\\\")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
params.push(`${escaped}%`);
}
if (options?.schema !== undefined) {
conditions.push("schema = ?");
params.push(options.schema);
}
const sortCol = options?.sort === "updated" ? "updated" : "created";
const sortDir = options?.desc ? "DESC" : "ASC";
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Post-filter by tags and labels (stored as JSON)
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const needsPostFilter =
Object.keys(filterTags).length > 0 || filterLabels.length > 0;
// When post-filtering, fetch all matching rows (no SQL LIMIT)
// then apply limit/offset after filtering
let sql: string;
if (needsPostFilter) {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
} else {
sql = `SELECT * FROM vars ${where} ORDER BY ${sortCol} ${sortDir}, name ASC`;
if (limit !== undefined || (options?.offset ?? 0) > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${options?.offset ?? 0}`;
}
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
if (!needsPostFilter) return rows.map(toVariable);
let results: Variable[] = [];
for (const row of rows) {
const v = toVariable(row);
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (v.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!v.labels.includes(lb)) {
ok = false;
break;
}
}
if (ok) results.push(v);
}
// Apply limit/offset after post-filter
const offset = options?.offset ?? 0;
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results;
},
history(name: string, schema?: Hash): HistoryEntry[] {
if (schema !== undefined) {
return (
stmtGetHistory.all(name, schema) as Record<string, unknown>[]
).map(toHistoryEntry);
}
const vars = stmtGetByName.all(name) as Record<string, unknown>[];
if (vars.length !== 1) return [];
const v = vars[0]!;
return (
stmtGetHistory.all(v.name as string, v.schema as string) as Record<
string,
unknown
>[]
).map(toHistoryEntry);
},
close(): void {
if (closed) return;
closed = true;
db.close();
},
};
// ── TagStore implementation ──
const tagStore: TagStore = {
tag(target: Hash, operations: TagOp[]): Tag[] {
const now = Date.now();
txnTagOps(target, operations, now);
return (
stmtGetTagsByTarget.all(target) as Record<string, unknown>[]
).map((r) => toTag(r, target));
},
untag(target: Hash, keys: string[]): void {
txnUntag(target, keys);
},
tags(target: Hash): Tag[] {
return (
stmtGetTagsByTarget.all(target) as Record<string, unknown>[]
).map((r) => toTag(r, target));
},
listByTag(tag: string, options?: ListOptions): Hash[] {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
// Build SQL with sort/limit/offset pushed down
const sortCol = "created"; // tags only have created
const sortDir = options?.desc ? "DESC" : "ASC";
const offset = options?.offset ?? 0;
const limit = options?.limit;
let sql: string;
const params: unknown[] = [key];
if (value !== undefined) {
sql = `SELECT target FROM tags WHERE key = ? AND value = ? ORDER BY ${sortCol} ${sortDir}`;
params.push(value);
} else {
sql = `SELECT target FROM tags WHERE key = ? ORDER BY ${sortCol} ${sortDir}`;
}
if (limit !== undefined || offset > 0) {
sql += ` LIMIT ${limit ?? -1} OFFSET ${offset}`;
}
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
return rows.map((r) => r.target as Hash);
},
};
return {
var: varStore,
tag: tagStore,
close: () => {
if (closed) return;
closed = true;
db.close();
},
};
}
+4 -3
View File
@@ -27,7 +27,7 @@ import {
type Store,
} from "@ocas/core";
import { decode } from "cborg";
import { createFsTagStore, createFsVarStoreFor } from "./var-store.js";
import { createSqliteVarStore } from "./sqlite-store.js";
const INDEX_DIR = "_index";
const META_FILE = "_meta";
@@ -393,10 +393,11 @@ export async function prepareStore(dir: string): Promise<FsCasStore> {
*/
export async function openStore(dir: string): Promise<Store> {
const cas = await prepareStore(dir);
const sqlite = createSqliteVarStore(dir, cas);
const ocas: Store = {
cas,
var: createFsVarStoreFor(dir, cas),
tag: createFsTagStore(dir),
var: sqlite.var,
tag: sqlite.tag,
};
bootstrap(ocas);
return ocas;
+5 -14
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openStore } from "./store.js";
@@ -16,7 +16,7 @@ describe("FsTagStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("B1. set tag with key/value round-trip + JSONL persisted", async () => {
test("B1. set tag with key/value round-trip + SQLite persisted", async () => {
const store = await openStore(dir);
const result = store.tag.tag(T1, [
{ op: "set", key: "env", value: "prod" },
@@ -26,17 +26,8 @@ describe("FsTagStore", () => {
expect(result[0]?.value).toBe("prod");
expect(store.tag.tags(T1)).toEqual(result);
const jsonl = join(dir, "_tags.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0] as string) as {
key: string;
value: string;
};
expect(parsed.key).toBe("env");
expect(parsed.value).toBe("prod");
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
});
test("B2. label tag (no value) records value: null", async () => {
@@ -120,7 +111,7 @@ describe("FsTagStore", () => {
expect(tags.map((t) => t.value)).toEqual(["prod", "platform"]);
});
test("B11. JSONL replay fidelity (set/delete/untag mix)", async () => {
test("B11. SQLite replay fidelity (set/delete/untag mix)", async () => {
const store = await openStore(dir);
store.tag.tag(T1, [{ op: "set", key: "a", value: "1" }]);
store.tag.tag(T1, [{ op: "set", key: "b", value: "2" }]);
+4 -13
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
@@ -40,7 +40,7 @@ describe("FsVarStore", () => {
rmSync(dir, { recursive: true, force: true });
});
test("A1. set + get round-trip persists to JSONL", async () => {
test("A1. set + get round-trip persists to SQLite", async () => {
const { store, schema, put } = await setupStore(dir);
const h = put("hello");
const v = store.var.set("@app/x", h);
@@ -51,17 +51,8 @@ describe("FsVarStore", () => {
const got = store.var.get("@app/x", schema);
expect(got?.value).toBe(h);
const jsonl = join(dir, "_vars.jsonl");
expect(existsSync(jsonl)).toBe(true);
const content = readFileSync(jsonl, "utf8");
expect(content.length).toBeGreaterThan(0);
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(1);
const matching = lines
.map((l) => JSON.parse(l) as { name?: string; value?: Hash })
.find((r) => r.name === "@app/x");
expect(matching).toBeDefined();
expect(matching?.value).toBe(h);
const dbFile = join(dir, "_store.db");
expect(existsSync(dbFile)).toBe(true);
});
test("A2. name validation", async () => {
-418
View File
@@ -1,418 +0,0 @@
import {
appendFileSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type {
CasStore,
Hash,
ListEntry,
Tag,
TagStore,
Variable,
VarListOptions,
VarStore,
} from "@ocas/core";
import {
addNameIndex,
applyListOptions,
casListEntry,
checkTagLabelConflict,
cloneVarRecord,
extractSchema,
pushHistory,
removeNameIndex,
SchemaMismatchError,
VariableNotFoundError,
type VarRecord,
validateName,
varKey,
} from "@ocas/core";
const VARS_FILE = "_vars.jsonl";
const TAGS_FILE = "_tags.jsonl";
export function createFsVarStoreFor(dir: string, cas: CasStore): VarStore {
const records = new Map<string, VarRecord>();
const byName = new Map<string, Set<string>>();
const path = join(dir, VARS_FILE);
// Load existing records (last record per key wins)
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const rec = JSON.parse(line) as VarRecord & { __op?: string };
if (rec.__op === "remove") {
const k = varKey(rec.name, rec.schema);
records.delete(k);
removeNameIndex(byName, rec.name, k);
} else {
const k = varKey(rec.name, rec.schema);
records.set(k, rec);
addNameIndex(byName, rec.name, k);
}
} catch {
// skip malformed
}
}
} catch {
// file may not exist
}
function persistFull(): void {
mkdirSync(dir, { recursive: true });
const lines: string[] = [];
for (const rec of records.values()) {
lines.push(JSON.stringify(rec));
}
writeFileSync(path, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
}
function appendRecord(rec: VarRecord): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
}
function appendRemoval(name: string, schema: Hash): void {
mkdirSync(dir, { recursive: true });
appendFileSync(
path,
`${JSON.stringify({ __op: "remove", name, schema })}\n`,
"utf8",
);
}
return {
set(name, hash, options) {
validateName(name);
const schema = extractSchema(cas, hash);
const k = varKey(name, schema);
const existing = records.get(k);
const now = Date.now();
if (existing) {
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
}
const tags = options?.tags ?? {};
const labels = options?.labels ?? [];
checkTagLabelConflict(tags, labels);
const rec: VarRecord = {
name,
schema,
value: hash,
created: now,
updated: now,
tags: { ...tags },
labels: [...labels],
history: [{ value: hash, position: 0, setAt: now }],
};
records.set(k, rec);
addNameIndex(byName, name, k);
appendRecord(rec);
return cloneVarRecord(rec);
},
get(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? cloneVarRecord(rec) : null;
}
const set = byName.get(name);
if (!set || set.size !== 1) return null;
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return null;
const rec = records.get(onlyKey);
return rec ? cloneVarRecord(rec) : null;
},
remove(name, schema) {
if (schema !== undefined) {
const k = varKey(name, schema);
const rec = records.get(k);
if (!rec) return [];
records.delete(k);
removeNameIndex(byName, name, k);
appendRemoval(name, schema);
return [cloneVarRecord(rec)];
}
const set = byName.get(name);
if (!set) return [];
const removed: Variable[] = [];
for (const k of [...set]) {
const rec = records.get(k);
if (rec) {
removed.push(cloneVarRecord(rec));
records.delete(k);
appendRemoval(rec.name, rec.schema);
}
}
byName.delete(name);
return removed;
},
update(name, hash, options) {
validateName(name);
const newSchema = extractSchema(cas, hash);
const set = byName.get(name);
if (!set || set.size === 0)
throw new VariableNotFoundError(name, newSchema);
const k = varKey(name, newSchema);
const existing = records.get(k);
if (!existing) {
for (const ek of set) {
const erec = records.get(ek);
if (erec) throw new SchemaMismatchError(erec.schema, newSchema);
}
throw new VariableNotFoundError(name, newSchema);
}
const now = Date.now();
const tags = options?.tags ?? existing.tags;
const labels = options?.labels ?? existing.labels;
if (options !== undefined) checkTagLabelConflict(tags, labels);
const changed = pushHistory(existing, hash, now);
if (changed) {
existing.value = hash;
existing.updated = now;
}
if (options !== undefined) {
existing.tags = { ...tags };
existing.labels = [...labels];
}
persistFull();
return cloneVarRecord(existing);
},
list(options?: VarListOptions) {
if (
options?.namePrefix !== undefined &&
options?.exactName !== undefined
) {
throw new Error(
"namePrefix and exactName are mutually exclusive - cannot specify both",
);
}
const namePrefix = options?.namePrefix;
const exactName = options?.exactName;
const schema = options?.schema;
const filterTags = options?.tags ?? {};
const filterLabels = options?.labels ?? [];
const sort = options?.sort ?? "created";
const desc = options?.desc ?? false;
const limit = options?.limit;
const offset = options?.offset ?? 0;
if (limit !== undefined && limit <= 0) return [];
let results: VarRecord[] = [];
for (const rec of records.values()) {
if (exactName !== undefined && rec.name !== exactName) continue;
if (namePrefix !== undefined && !rec.name.startsWith(namePrefix))
continue;
if (schema !== undefined && rec.schema !== schema) continue;
let ok = true;
for (const [tk, tv] of Object.entries(filterTags)) {
if (rec.tags[tk] !== tv) {
ok = false;
break;
}
}
if (!ok) continue;
for (const lb of filterLabels) {
if (!rec.labels.includes(lb)) {
ok = false;
break;
}
}
if (!ok) continue;
results.push(rec);
}
results.sort((a, b) => {
const av = sort === "updated" ? a.updated : a.created;
const bv = sort === "updated" ? b.updated : b.created;
if (av !== bv) return desc ? bv - av : av - bv;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
if (offset > 0) results = results.slice(offset);
if (limit !== undefined) results = results.slice(0, limit);
return results.map(cloneVarRecord);
},
history(name, schema) {
if (schema !== undefined) {
const rec = records.get(varKey(name, schema));
return rec ? rec.history.map((e) => ({ ...e })) : [];
}
const set = byName.get(name);
if (!set || set.size !== 1) return [];
const onlyKey = set.values().next().value;
if (onlyKey === undefined) return [];
const rec = records.get(onlyKey);
return rec ? rec.history.map((e) => ({ ...e })) : [];
},
close() {
// no-op (synchronous file ops)
},
};
}
type StoredTag = {
key: string;
value: string | null;
target: Hash;
created: number;
};
export function createFsTagStore(dir: string): TagStore {
const byTarget = new Map<Hash, Map<string, Tag>>();
const byKey = new Map<string, Set<Hash>>();
const path = join(dir, TAGS_FILE);
function addKeyIndex(k: string, target: Hash): void {
let set = byKey.get(k);
if (!set) {
set = new Set();
byKey.set(k, set);
}
set.add(target);
}
function removeKeyIndex(k: string, target: Hash): void {
const set = byKey.get(k);
if (!set) return;
const tmap = byTarget.get(target);
if (tmap?.has(k)) return;
set.delete(target);
if (set.size === 0) byKey.delete(k);
}
// Load
try {
const content = readFileSync(path, "utf8");
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
const ent = JSON.parse(line) as
| (StoredTag & { __op?: "set" | "untag" })
| { __op: "untag"; target: Hash; key: string };
if ((ent as { __op?: string }).__op === "untag") {
const e = ent as { target: Hash; key: string };
const tm = byTarget.get(e.target);
if (tm) {
tm.delete(e.key);
removeKeyIndex(e.key, e.target);
if (tm.size === 0) byTarget.delete(e.target);
}
} else {
const t = ent as StoredTag;
let tm = byTarget.get(t.target);
if (!tm) {
tm = new Map();
byTarget.set(t.target, tm);
}
tm.set(t.key, {
key: t.key,
value: t.value,
target: t.target,
created: t.created,
});
addKeyIndex(t.key, t.target);
}
} catch {
// skip
}
}
} catch {
// none
}
function append(line: object): void {
mkdirSync(dir, { recursive: true });
appendFileSync(path, `${JSON.stringify(line)}\n`, "utf8");
}
return {
tag(target, ops) {
let tm = byTarget.get(target);
if (!tm) {
tm = new Map();
byTarget.set(target, tm);
}
const now = Date.now();
for (const op of ops) {
if (op.op === "set") {
const existing = tm.get(op.key);
const tag: Tag = {
key: op.key,
value: op.value ?? null,
target,
created: existing?.created ?? now,
};
tm.set(op.key, tag);
addKeyIndex(op.key, target);
append(tag);
} else {
tm.delete(op.key);
removeKeyIndex(op.key, target);
append({ __op: "untag", target, key: op.key });
}
}
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
untag(target, keys) {
const tm = byTarget.get(target);
if (!tm) return;
for (const k of keys) {
tm.delete(k);
removeKeyIndex(k, target);
append({ __op: "untag", target, key: k });
}
if (tm.size === 0) byTarget.delete(target);
},
tags(target) {
const tm = byTarget.get(target);
if (!tm) return [];
return [...tm.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
);
},
listByTag(tag, options) {
let key = tag;
let value: string | null | undefined;
const eqIdx = tag.indexOf("=");
if (eqIdx >= 0) {
key = tag.slice(0, eqIdx);
value = tag.slice(eqIdx + 1);
}
const targets = byKey.get(key);
if (!targets) return [];
let entries: ListEntry[] = [];
for (const t of targets) {
const tm = byTarget.get(t);
if (!tm) continue;
const tagEntry = tm.get(key);
if (!tagEntry) continue;
if (value !== undefined && tagEntry.value !== value) continue;
entries.push(casListEntry(t, tagEntry.created));
}
entries = applyListOptions(entries, options);
return entries.map((e) => e.hash);
},
};
}
+271 -2
View File
@@ -64,10 +64,16 @@ importers:
'@ocas/core':
specifier: workspace:*
version: link:../core
better-sqlite3:
specifier: ^12.10.0
version: 12.10.0
cborg:
specifier: ^4.2.3
version: 4.5.8
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/node':
specifier: ^25.9.1
version: 25.9.1
@@ -1009,6 +1015,9 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1069,9 +1078,25 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.10.0:
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
cborg@4.5.8:
resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==}
hasBin: true
@@ -1080,6 +1105,9 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1108,6 +1136,14 @@ packages:
data-uri-to-buffer@2.0.2:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
@@ -1115,6 +1151,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -1147,6 +1186,10 @@ packages:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1169,6 +1212,12 @@ packages:
picomatch:
optional: true
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1177,9 +1226,21 @@ packages:
get-source@2.0.12:
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
@@ -1279,11 +1340,21 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
miniflare@3.20250718.3:
resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==}
engines: {node: '>=16.13'}
hasBin: true
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
@@ -1293,12 +1364,22 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@@ -1316,9 +1397,26 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -1343,6 +1441,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
@@ -1355,6 +1456,12 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@@ -1383,6 +1490,20 @@ packages:
resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
engines: {node: '>=4', npm: '>=6'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1406,6 +1527,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1428,6 +1552,9 @@ packages:
unenv@2.0.0-rc.14:
resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1563,6 +1690,9 @@ packages:
'@cloudflare/workers-types':
optional: true
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
@@ -2142,6 +2272,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.9.1
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -2213,12 +2347,36 @@ snapshots:
assertion-error@2.0.1: {}
base64-js@1.5.1: {}
better-sqlite3@12.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
blake3-wasm@2.1.5: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
cborg@4.5.8: {}
chai@6.2.2: {}
chownr@1.1.4: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2247,10 +2405,20 @@ snapshots:
data-uri-to-buffer@2.0.2: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
defu@6.1.7: {}
detect-libc@2.1.2: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
es-module-lexer@2.1.0: {}
esbuild@0.17.19:
@@ -2343,6 +2511,8 @@ snapshots:
exit-hook@2.2.1: {}
expand-template@2.0.3: {}
expect-type@1.3.0: {}
exsolve@1.0.8: {}
@@ -2355,6 +2525,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
file-uri-to-path@1.0.0: {}
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
@@ -2363,8 +2537,16 @@ snapshots:
data-uri-to-buffer: 2.0.2
source-map: 0.6.1
github-from-package@0.0.0: {}
glob-to-regexp@0.4.1: {}
ieee754@1.2.1: {}
inherits@2.0.4: {}
ini@1.3.8: {}
is-arrayish@0.3.4:
optional: true
@@ -2435,6 +2617,8 @@ snapshots:
mime@3.0.0: {}
mimic-response@3.1.0: {}
miniflare@3.20250718.3:
dependencies:
'@cspotcode/source-map-support': 0.8.1
@@ -2452,14 +2636,28 @@ snapshots:
- bufferutil
- utf-8-validate
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
mustache@4.2.0: {}
nanoid@3.3.12: {}
napi-build-utils@2.0.0: {}
node-abi@3.92.0:
dependencies:
semver: 7.8.1
obug@2.1.1: {}
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
@@ -2474,8 +2672,41 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.92.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
printable-characters@1.0.42: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
require-from-string@2.0.2: {}
rolldown@1.0.3:
@@ -2544,8 +2775,9 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.61.0
fsevents: 2.3.3
semver@7.8.1:
optional: true
safe-buffer@5.2.1: {}
semver@7.8.1: {}
sharp@0.33.5:
dependencies:
@@ -2576,6 +2808,14 @@ snapshots:
siginfo@2.0.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@@ -2598,6 +2838,27 @@ snapshots:
stoppable@1.1.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
@@ -2618,6 +2879,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
typescript@5.9.3: {}
ufo@1.6.4: {}
@@ -2640,6 +2905,8 @@ snapshots:
pathe: 2.0.3
ufo: 1.6.4
util-deprecate@1.0.2: {}
vite@5.4.21(@types/node@25.9.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
@@ -2723,6 +2990,8 @@ snapshots:
- bufferutil
- utf-8-validate
wrappy@1.0.2: {}
ws@8.18.0: {}
xxhash-wasm@1.1.0: {}