memwarden 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +402 -0
- package/dist/bundle/bundle.d.ts +28 -0
- package/dist/bundle/bundle.js +85 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +593 -0
- package/dist/cli/connect.d.ts +63 -0
- package/dist/cli/connect.js +121 -0
- package/dist/cli/hook.d.ts +24 -0
- package/dist/cli/hook.js +186 -0
- package/dist/cli/tools.d.ts +47 -0
- package/dist/cli/tools.js +246 -0
- package/dist/daemon/ensure.d.ts +12 -0
- package/dist/daemon/ensure.js +54 -0
- package/dist/daemon/service.d.ts +15 -0
- package/dist/daemon/service.js +210 -0
- package/dist/embedding/index.d.ts +10 -0
- package/dist/embedding/index.js +33 -0
- package/dist/embedding/local-embedding.d.ts +14 -0
- package/dist/embedding/local-embedding.js +80 -0
- package/dist/functions/access-tracker.d.ts +13 -0
- package/dist/functions/access-tracker.js +92 -0
- package/dist/functions/audit.d.ts +46 -0
- package/dist/functions/audit.js +0 -0
- package/dist/functions/cjk-segmenter.d.ts +6 -0
- package/dist/functions/cjk-segmenter.js +120 -0
- package/dist/functions/compress-synthetic.d.ts +2 -0
- package/dist/functions/compress-synthetic.js +104 -0
- package/dist/functions/config.d.ts +68 -0
- package/dist/functions/config.js +231 -0
- package/dist/functions/conflicts.d.ts +19 -0
- package/dist/functions/conflicts.js +328 -0
- package/dist/functions/context.d.ts +3 -0
- package/dist/functions/context.js +155 -0
- package/dist/functions/dedup.d.ts +11 -0
- package/dist/functions/dedup.js +51 -0
- package/dist/functions/dejafix.d.ts +96 -0
- package/dist/functions/dejafix.js +356 -0
- package/dist/functions/doctor.d.ts +29 -0
- package/dist/functions/doctor.js +137 -0
- package/dist/functions/forget.d.ts +3 -0
- package/dist/functions/forget.js +87 -0
- package/dist/functions/hybrid-search.d.ts +17 -0
- package/dist/functions/hybrid-search.js +205 -0
- package/dist/functions/index.d.ts +32 -0
- package/dist/functions/index.js +44 -0
- package/dist/functions/keyed-mutex.d.ts +1 -0
- package/dist/functions/keyed-mutex.js +21 -0
- package/dist/functions/logger.d.ts +6 -0
- package/dist/functions/logger.js +37 -0
- package/dist/functions/memory-utils.d.ts +2 -0
- package/dist/functions/memory-utils.js +29 -0
- package/dist/functions/observe.d.ts +5 -0
- package/dist/functions/observe.js +326 -0
- package/dist/functions/paths.d.ts +1 -0
- package/dist/functions/paths.js +38 -0
- package/dist/functions/privacy.d.ts +1 -0
- package/dist/functions/privacy.js +30 -0
- package/dist/functions/provenance.d.ts +9 -0
- package/dist/functions/provenance.js +57 -0
- package/dist/functions/quantized-vector-index.d.ts +60 -0
- package/dist/functions/quantized-vector-index.js +275 -0
- package/dist/functions/receipt.d.ts +31 -0
- package/dist/functions/receipt.js +95 -0
- package/dist/functions/search-index.d.ts +27 -0
- package/dist/functions/search-index.js +217 -0
- package/dist/functions/search.d.ts +25 -0
- package/dist/functions/search.js +523 -0
- package/dist/functions/stemmer.d.ts +1 -0
- package/dist/functions/stemmer.js +110 -0
- package/dist/functions/synonyms.d.ts +1 -0
- package/dist/functions/synonyms.js +69 -0
- package/dist/functions/turboquant.d.ts +53 -0
- package/dist/functions/turboquant.js +278 -0
- package/dist/functions/types.d.ts +217 -0
- package/dist/functions/types.js +8 -0
- package/dist/functions/vector-index.d.ts +25 -0
- package/dist/functions/vector-index.js +125 -0
- package/dist/functions/vector-persistence.d.ts +14 -0
- package/dist/functions/vector-persistence.js +75 -0
- package/dist/functions/verify.d.ts +13 -0
- package/dist/functions/verify.js +104 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +219 -0
- package/dist/kernel/http.d.ts +24 -0
- package/dist/kernel/http.js +261 -0
- package/dist/kernel/index.d.ts +19 -0
- package/dist/kernel/index.js +21 -0
- package/dist/kernel/kernel.d.ts +80 -0
- package/dist/kernel/kernel.js +297 -0
- package/dist/kernel/pubsub.d.ts +21 -0
- package/dist/kernel/pubsub.js +38 -0
- package/dist/kernel/types.d.ts +139 -0
- package/dist/kernel/types.js +20 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +27 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.js +377 -0
- package/dist/observability/metrics.d.ts +26 -0
- package/dist/observability/metrics.js +104 -0
- package/dist/proxy/server.d.ts +30 -0
- package/dist/proxy/server.js +331 -0
- package/dist/state/kv.d.ts +41 -0
- package/dist/state/kv.js +50 -0
- package/dist/state/oplog.d.ts +25 -0
- package/dist/state/oplog.js +57 -0
- package/dist/state/schema.d.ts +60 -0
- package/dist/state/schema.js +88 -0
- package/dist/state/store-libsql.d.ts +46 -0
- package/dist/state/store-libsql.js +263 -0
- package/dist/state/store-memory.d.ts +23 -0
- package/dist/state/store-memory.js +121 -0
- package/dist/state/store.d.ts +87 -0
- package/dist/state/store.js +58 -0
- package/dist/triggers/api.d.ts +14 -0
- package/dist/triggers/api.js +510 -0
- package/dist/triggers/auth.d.ts +1 -0
- package/dist/triggers/auth.js +13 -0
- package/package.json +58 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare const KV: {
|
|
2
|
+
readonly sessions: "mem:sessions";
|
|
3
|
+
readonly observations: (sessionId: string) => string;
|
|
4
|
+
readonly memories: "mem:memories";
|
|
5
|
+
readonly summaries: "mem:summaries";
|
|
6
|
+
readonly config: "mem:config";
|
|
7
|
+
readonly metrics: "mem:metrics";
|
|
8
|
+
readonly health: "mem:health";
|
|
9
|
+
readonly embeddings: (obsId: string) => string;
|
|
10
|
+
readonly bm25Index: "mem:index:bm25";
|
|
11
|
+
readonly relations: "mem:relations";
|
|
12
|
+
readonly profiles: "mem:profiles";
|
|
13
|
+
readonly claudeBridge: "mem:claude-bridge";
|
|
14
|
+
readonly graphNodes: "mem:graph:nodes";
|
|
15
|
+
readonly graphEdges: "mem:graph:edges";
|
|
16
|
+
readonly semantic: "mem:semantic";
|
|
17
|
+
readonly procedural: "mem:procedural";
|
|
18
|
+
readonly teamShared: (teamId: string) => string;
|
|
19
|
+
readonly teamUsers: (teamId: string, userId: string) => string;
|
|
20
|
+
readonly teamProfile: (teamId: string) => string;
|
|
21
|
+
readonly audit: "mem:audit";
|
|
22
|
+
readonly actions: "mem:actions";
|
|
23
|
+
readonly actionEdges: "mem:action-edges";
|
|
24
|
+
readonly leases: "mem:leases";
|
|
25
|
+
readonly routines: "mem:routines";
|
|
26
|
+
readonly routineRuns: "mem:routine-runs";
|
|
27
|
+
readonly signals: "mem:signals";
|
|
28
|
+
readonly checkpoints: "mem:checkpoints";
|
|
29
|
+
readonly mesh: "mem:mesh";
|
|
30
|
+
readonly sketches: "mem:sketches";
|
|
31
|
+
readonly facets: "mem:facets";
|
|
32
|
+
readonly sentinels: "mem:sentinels";
|
|
33
|
+
readonly crystals: "mem:crystals";
|
|
34
|
+
readonly lessons: "mem:lessons";
|
|
35
|
+
readonly insights: "mem:insights";
|
|
36
|
+
readonly graphEdgeHistory: "mem:graph:edge-history";
|
|
37
|
+
readonly enrichedChunks: (sessionId: string) => string;
|
|
38
|
+
readonly latentEmbeddings: (obsId: string) => string;
|
|
39
|
+
readonly quantParams: "mem:quant:params";
|
|
40
|
+
readonly retentionScores: "mem:retention";
|
|
41
|
+
readonly accessLog: "mem:access";
|
|
42
|
+
readonly imageRefs: "mem:image-refs";
|
|
43
|
+
readonly imageEmbeddings: "mem:image-embeddings";
|
|
44
|
+
readonly slots: "mem:slots";
|
|
45
|
+
readonly globalSlots: "mem:slots:global";
|
|
46
|
+
readonly state: "mem:state";
|
|
47
|
+
readonly commits: "mem:commits";
|
|
48
|
+
readonly recentSearches: "mem:recent-searches";
|
|
49
|
+
};
|
|
50
|
+
export declare const STREAM: {
|
|
51
|
+
readonly name: "mem-live";
|
|
52
|
+
readonly group: (sessionId: string) => string;
|
|
53
|
+
readonly viewerGroup: "viewer";
|
|
54
|
+
};
|
|
55
|
+
/** A sortable, collision-resistant id: prefix + base36 time + random tail. */
|
|
56
|
+
export declare function generateId(prefix: string): string;
|
|
57
|
+
/** A deterministic id derived from content (same content -> same id). */
|
|
58
|
+
export declare function fingerprintId(prefix: string, content: string): string;
|
|
59
|
+
/** Word-set Jaccard overlap, ignoring tokens of 2 characters or fewer. */
|
|
60
|
+
export declare function jaccardSimilarity(a: string, b: string): number;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//
|
|
2
|
+
// The single source of truth for KV scope names, plus a few pure id and
|
|
3
|
+
// similarity helpers. Scopes are exact-match strings (no prefix hierarchy):
|
|
4
|
+
// list(scope) returns just that scope's values, and cross-session enumeration
|
|
5
|
+
// is list(KV.sessions) followed by list(KV.observations(id)) per session.
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
export const KV = {
|
|
8
|
+
sessions: "mem:sessions",
|
|
9
|
+
observations: (sessionId) => `mem:obs:${sessionId}`,
|
|
10
|
+
memories: "mem:memories",
|
|
11
|
+
summaries: "mem:summaries",
|
|
12
|
+
config: "mem:config",
|
|
13
|
+
metrics: "mem:metrics",
|
|
14
|
+
health: "mem:health",
|
|
15
|
+
embeddings: (obsId) => `mem:emb:${obsId}`,
|
|
16
|
+
bm25Index: "mem:index:bm25",
|
|
17
|
+
relations: "mem:relations",
|
|
18
|
+
profiles: "mem:profiles",
|
|
19
|
+
claudeBridge: "mem:claude-bridge",
|
|
20
|
+
graphNodes: "mem:graph:nodes",
|
|
21
|
+
graphEdges: "mem:graph:edges",
|
|
22
|
+
semantic: "mem:semantic",
|
|
23
|
+
procedural: "mem:procedural",
|
|
24
|
+
teamShared: (teamId) => `mem:team:${teamId}:shared`,
|
|
25
|
+
teamUsers: (teamId, userId) => `mem:team:${teamId}:users:${userId}`,
|
|
26
|
+
teamProfile: (teamId) => `mem:team:${teamId}:profile`,
|
|
27
|
+
audit: "mem:audit",
|
|
28
|
+
actions: "mem:actions",
|
|
29
|
+
actionEdges: "mem:action-edges",
|
|
30
|
+
leases: "mem:leases",
|
|
31
|
+
routines: "mem:routines",
|
|
32
|
+
routineRuns: "mem:routine-runs",
|
|
33
|
+
signals: "mem:signals",
|
|
34
|
+
checkpoints: "mem:checkpoints",
|
|
35
|
+
mesh: "mem:mesh",
|
|
36
|
+
sketches: "mem:sketches",
|
|
37
|
+
facets: "mem:facets",
|
|
38
|
+
sentinels: "mem:sentinels",
|
|
39
|
+
crystals: "mem:crystals",
|
|
40
|
+
lessons: "mem:lessons",
|
|
41
|
+
insights: "mem:insights",
|
|
42
|
+
graphEdgeHistory: "mem:graph:edge-history",
|
|
43
|
+
enrichedChunks: (sessionId) => `mem:enriched:${sessionId}`,
|
|
44
|
+
// Reserved per-observation quantized codes; a later phase persists the whole
|
|
45
|
+
// quantized index as one blob under `quantParams` instead.
|
|
46
|
+
latentEmbeddings: (obsId) => `mem:latent:${obsId}`,
|
|
47
|
+
quantParams: "mem:quant:params",
|
|
48
|
+
retentionScores: "mem:retention",
|
|
49
|
+
accessLog: "mem:access",
|
|
50
|
+
imageRefs: "mem:image-refs",
|
|
51
|
+
imageEmbeddings: "mem:image-embeddings",
|
|
52
|
+
slots: "mem:slots",
|
|
53
|
+
globalSlots: "mem:slots:global",
|
|
54
|
+
state: "mem:state",
|
|
55
|
+
commits: "mem:commits",
|
|
56
|
+
recentSearches: "mem:recent-searches",
|
|
57
|
+
};
|
|
58
|
+
export const STREAM = {
|
|
59
|
+
name: "mem-live",
|
|
60
|
+
group: (sessionId) => sessionId,
|
|
61
|
+
viewerGroup: "viewer",
|
|
62
|
+
};
|
|
63
|
+
/** A sortable, collision-resistant id: prefix + base36 time + random tail. */
|
|
64
|
+
export function generateId(prefix) {
|
|
65
|
+
const time = Date.now().toString(36);
|
|
66
|
+
const tail = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
67
|
+
return `${prefix}_${time}_${tail}`;
|
|
68
|
+
}
|
|
69
|
+
/** A deterministic id derived from content (same content -> same id). */
|
|
70
|
+
export function fingerprintId(prefix, content) {
|
|
71
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
72
|
+
return `${prefix}_${digest.slice(0, 16)}`;
|
|
73
|
+
}
|
|
74
|
+
/** Word-set Jaccard overlap, ignoring tokens of 2 characters or fewer. */
|
|
75
|
+
export function jaccardSimilarity(a, b) {
|
|
76
|
+
const words = (s) => new Set(s.split(/\s+/).filter((t) => t.length > 2));
|
|
77
|
+
const left = words(a);
|
|
78
|
+
const right = words(b);
|
|
79
|
+
if (left.size === 0 && right.size === 0)
|
|
80
|
+
return 1;
|
|
81
|
+
if (left.size === 0 || right.size === 0)
|
|
82
|
+
return 0;
|
|
83
|
+
let shared = 0;
|
|
84
|
+
for (const w of left)
|
|
85
|
+
if (right.has(w))
|
|
86
|
+
shared++;
|
|
87
|
+
return shared / (left.size + right.size - shared);
|
|
88
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type MutationListener, type OplogEntry, type StateStore, type UpdateOp } from "./store.js";
|
|
2
|
+
export interface StoreLibsqlOptions {
|
|
3
|
+
/** libSQL URL: `file:/path/to/mem.db` or `:memory:`. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Optional auth token (for remote libSQL/Turso; unused for local files). */
|
|
6
|
+
authToken?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class StoreLibsql implements StateStore {
|
|
9
|
+
private readonly client;
|
|
10
|
+
private readonly listeners;
|
|
11
|
+
/** Serializes writes so the oplog prev_hash read+append is atomic per-process. */
|
|
12
|
+
private writeChain;
|
|
13
|
+
private ready;
|
|
14
|
+
private closed;
|
|
15
|
+
constructor(options: StoreLibsqlOptions);
|
|
16
|
+
/** Idempotently apply the schema. Awaited by every public method. */
|
|
17
|
+
private init;
|
|
18
|
+
get<T = unknown>(scope: string, key: string): Promise<T | null>;
|
|
19
|
+
list<T = unknown>(scope: string): Promise<T[]>;
|
|
20
|
+
set<T = unknown>(scope: string, key: string, value: T): Promise<T>;
|
|
21
|
+
update<T = unknown>(scope: string, key: string, ops: readonly UpdateOp[]): Promise<T>;
|
|
22
|
+
delete(scope: string, key: string): Promise<void>;
|
|
23
|
+
onMutation(listener: MutationListener): () => void;
|
|
24
|
+
readOplog(sinceId?: number): Promise<OplogEntry[]>;
|
|
25
|
+
verifyOplog(): Promise<{
|
|
26
|
+
ok: true;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
brokenAt: number;
|
|
30
|
+
}>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Perform one mutation + its matching oplog append as a single atomic batch.
|
|
34
|
+
* Reads the current value (for old_value / the update base) and the oplog
|
|
35
|
+
* tail (for prev_hash) first; serialization via the write chain guarantees no
|
|
36
|
+
* other write interleaves between the reads and the batch commit. Returns the
|
|
37
|
+
* mutation event to emit, or null for a delete that hit nothing (idempotent
|
|
38
|
+
* no-op, no oplog row).
|
|
39
|
+
*/
|
|
40
|
+
private writeTx;
|
|
41
|
+
/** Build the hash-chained oplog INSERT statement for the next entry. */
|
|
42
|
+
private buildOplogInsert;
|
|
43
|
+
/** Chain writes so prev_hash reads never race a concurrent append. */
|
|
44
|
+
private serializeWrite;
|
|
45
|
+
private emit;
|
|
46
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
//
|
|
2
|
+
// libSQL-backed StateStore. Replaces the original file-based KV
|
|
3
|
+
// (./data/state_store.db, opaque blob) with a real relational store that
|
|
4
|
+
// gives `list(scope)` an exact index lookup instead of an O(N) scan, while
|
|
5
|
+
// preserving every observable StateKV semantic.
|
|
6
|
+
//
|
|
7
|
+
// Schema (one co-located file, `file:...` or `:memory:`):
|
|
8
|
+
// kv(scope TEXT, key TEXT, value TEXT JSON, created_at, updated_at,
|
|
9
|
+
// PRIMARY KEY (scope, key)) -- index on scope for list()
|
|
10
|
+
// oplog(id INTEGER PK AUTOINCREMENT, ts, op, scope, key,
|
|
11
|
+
// payload TEXT JSON, prev_hash, hash)
|
|
12
|
+
//
|
|
13
|
+
// Writes (set/update/delete) read the current value + oplog tail, then commit
|
|
14
|
+
// the `kv` change AND the matching `oplog` row as a SINGLE atomic batch, so the
|
|
15
|
+
// hash chain and the data can never diverge. We use `batch` rather than an
|
|
16
|
+
// interactive transaction because the libSQL local driver opens a separate
|
|
17
|
+
// connection per interactive transaction, and a `:memory:` database is
|
|
18
|
+
// per-connection (the transaction would not see the schema/data). All writes
|
|
19
|
+
// are serialized through an in-process promise chain: the read-then-batch pair
|
|
20
|
+
// must be atomic with respect to other writes because `prev_hash` depends on
|
|
21
|
+
// the previous committed entry. This is correct only for a single-process
|
|
22
|
+
// kernel; a multi-process memwarden would need a real lock (the same caveat
|
|
23
|
+
// as withKeyedLock).
|
|
24
|
+
import { createClient } from "@libsql/client";
|
|
25
|
+
import { applyUpdateOps, } from "./store.js";
|
|
26
|
+
import { GENESIS_PREV_HASH, hashOplogEntry, verifyChain } from "./oplog.js";
|
|
27
|
+
const SCHEMA = [
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS kv (
|
|
29
|
+
scope TEXT NOT NULL,
|
|
30
|
+
key TEXT NOT NULL,
|
|
31
|
+
value TEXT NOT NULL,
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
updated_at TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (scope, key)
|
|
35
|
+
)`,
|
|
36
|
+
`CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv (scope)`,
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS oplog (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
ts TEXT NOT NULL,
|
|
40
|
+
op TEXT NOT NULL,
|
|
41
|
+
scope TEXT NOT NULL,
|
|
42
|
+
key TEXT NOT NULL,
|
|
43
|
+
payload TEXT,
|
|
44
|
+
prev_hash TEXT NOT NULL,
|
|
45
|
+
hash TEXT NOT NULL
|
|
46
|
+
)`,
|
|
47
|
+
`CREATE INDEX IF NOT EXISTS idx_oplog_scope ON oplog (scope, key)`,
|
|
48
|
+
];
|
|
49
|
+
export class StoreLibsql {
|
|
50
|
+
client;
|
|
51
|
+
listeners = new Set();
|
|
52
|
+
/** Serializes writes so the oplog prev_hash read+append is atomic per-process. */
|
|
53
|
+
writeChain = Promise.resolve();
|
|
54
|
+
ready = null;
|
|
55
|
+
closed = false;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.client = createClient(options.authToken === undefined
|
|
58
|
+
? { url: options.url }
|
|
59
|
+
: { url: options.url, authToken: options.authToken });
|
|
60
|
+
}
|
|
61
|
+
/** Idempotently apply the schema. Awaited by every public method. */
|
|
62
|
+
init() {
|
|
63
|
+
if (!this.ready) {
|
|
64
|
+
this.ready = (async () => {
|
|
65
|
+
for (const stmt of SCHEMA) {
|
|
66
|
+
await this.client.execute(stmt);
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
}
|
|
70
|
+
return this.ready;
|
|
71
|
+
}
|
|
72
|
+
async get(scope, key) {
|
|
73
|
+
await this.init();
|
|
74
|
+
const res = await this.client.execute({
|
|
75
|
+
sql: `SELECT value FROM kv WHERE scope = ? AND key = ?`,
|
|
76
|
+
args: [scope, key],
|
|
77
|
+
});
|
|
78
|
+
const row = res.rows[0];
|
|
79
|
+
if (!row)
|
|
80
|
+
return null;
|
|
81
|
+
return decode(row.value);
|
|
82
|
+
}
|
|
83
|
+
async list(scope) {
|
|
84
|
+
await this.init();
|
|
85
|
+
// Insertion order: rowid is monotonic with insert order, and an upsert
|
|
86
|
+
// updates value in place without changing rowid, so ordering by rowid
|
|
87
|
+
// reproduces Map insertion-order semantics.
|
|
88
|
+
const res = await this.client.execute({
|
|
89
|
+
sql: `SELECT value FROM kv WHERE scope = ? ORDER BY rowid ASC`,
|
|
90
|
+
args: [scope],
|
|
91
|
+
});
|
|
92
|
+
return res.rows.map((row) => decode(row.value));
|
|
93
|
+
}
|
|
94
|
+
async set(scope, key, value) {
|
|
95
|
+
await this.serializeWrite(async () => {
|
|
96
|
+
// set always produces an event (never the delete-no-op null).
|
|
97
|
+
const event = await this.writeTx("set", scope, key, value);
|
|
98
|
+
if (event)
|
|
99
|
+
this.emit(event);
|
|
100
|
+
});
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
async update(scope, key, ops) {
|
|
104
|
+
let updated = {};
|
|
105
|
+
await this.serializeWrite(async () => {
|
|
106
|
+
// update always produces an event (never the delete-no-op null).
|
|
107
|
+
const event = await this.writeTx("update", scope, key, undefined, ops);
|
|
108
|
+
if (event) {
|
|
109
|
+
updated = event.new_value;
|
|
110
|
+
this.emit(event);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
async delete(scope, key) {
|
|
116
|
+
await this.serializeWrite(async () => {
|
|
117
|
+
const event = await this.writeTx("delete", scope, key, undefined);
|
|
118
|
+
// Only emit/log if the row actually existed (event is null otherwise).
|
|
119
|
+
if (event)
|
|
120
|
+
this.emit(event);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
onMutation(listener) {
|
|
124
|
+
this.listeners.add(listener);
|
|
125
|
+
return () => {
|
|
126
|
+
this.listeners.delete(listener);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async readOplog(sinceId) {
|
|
130
|
+
await this.init();
|
|
131
|
+
const res = await this.client.execute({
|
|
132
|
+
sql: `SELECT id, ts, op, scope, key, payload, prev_hash, hash
|
|
133
|
+
FROM oplog WHERE id > ? ORDER BY id ASC`,
|
|
134
|
+
args: [sinceId ?? 0],
|
|
135
|
+
});
|
|
136
|
+
return res.rows.map((row) => ({
|
|
137
|
+
id: Number(row.id),
|
|
138
|
+
ts: String(row.ts),
|
|
139
|
+
op: String(row.op),
|
|
140
|
+
scope: String(row.scope),
|
|
141
|
+
key: String(row.key),
|
|
142
|
+
payload: row.payload === null ? null : decode(row.payload),
|
|
143
|
+
prev_hash: String(row.prev_hash),
|
|
144
|
+
hash: String(row.hash),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
async verifyOplog() {
|
|
148
|
+
const entries = await this.readOplog();
|
|
149
|
+
const brokenAt = verifyChain(entries);
|
|
150
|
+
return brokenAt === null ? { ok: true } : { ok: false, brokenAt };
|
|
151
|
+
}
|
|
152
|
+
async close() {
|
|
153
|
+
if (this.closed)
|
|
154
|
+
return;
|
|
155
|
+
this.closed = true;
|
|
156
|
+
// Drain in-flight writes before closing the client.
|
|
157
|
+
await this.writeChain.catch(() => undefined);
|
|
158
|
+
this.listeners.clear();
|
|
159
|
+
this.client.close();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Perform one mutation + its matching oplog append as a single atomic batch.
|
|
163
|
+
* Reads the current value (for old_value / the update base) and the oplog
|
|
164
|
+
* tail (for prev_hash) first; serialization via the write chain guarantees no
|
|
165
|
+
* other write interleaves between the reads and the batch commit. Returns the
|
|
166
|
+
* mutation event to emit, or null for a delete that hit nothing (idempotent
|
|
167
|
+
* no-op, no oplog row).
|
|
168
|
+
*/
|
|
169
|
+
async writeTx(op, scope, key, value, ops) {
|
|
170
|
+
await this.init();
|
|
171
|
+
const cur = await this.client.execute({
|
|
172
|
+
sql: `SELECT value FROM kv WHERE scope = ? AND key = ?`,
|
|
173
|
+
args: [scope, key],
|
|
174
|
+
});
|
|
175
|
+
const existingRow = cur.rows[0];
|
|
176
|
+
const oldValue = existingRow ? decode(existingRow.value) : undefined;
|
|
177
|
+
let newValue;
|
|
178
|
+
let mutation;
|
|
179
|
+
let event;
|
|
180
|
+
if (op === "delete") {
|
|
181
|
+
if (!existingRow)
|
|
182
|
+
return null;
|
|
183
|
+
newValue = null;
|
|
184
|
+
mutation = {
|
|
185
|
+
sql: `DELETE FROM kv WHERE scope = ? AND key = ?`,
|
|
186
|
+
args: [scope, key],
|
|
187
|
+
};
|
|
188
|
+
event = {
|
|
189
|
+
scope,
|
|
190
|
+
key,
|
|
191
|
+
event_type: "delete",
|
|
192
|
+
...(oldValue === undefined ? {} : { old_value: oldValue }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (op === "update") {
|
|
197
|
+
const base = oldValue && typeof oldValue === "object" && !Array.isArray(oldValue)
|
|
198
|
+
? oldValue
|
|
199
|
+
: {};
|
|
200
|
+
newValue = applyUpdateOps({ ...base }, ops ?? []);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
newValue = value;
|
|
204
|
+
}
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
mutation = {
|
|
207
|
+
sql: `INSERT INTO kv (scope, key, value, created_at, updated_at)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?)
|
|
209
|
+
ON CONFLICT(scope, key)
|
|
210
|
+
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
211
|
+
args: [scope, key, encode(newValue), now, now],
|
|
212
|
+
};
|
|
213
|
+
event = {
|
|
214
|
+
scope,
|
|
215
|
+
key,
|
|
216
|
+
event_type: op,
|
|
217
|
+
...(oldValue === undefined ? {} : { old_value: oldValue }),
|
|
218
|
+
new_value: newValue,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const oplogStmt = await this.buildOplogInsert(op, scope, key, newValue);
|
|
222
|
+
await this.client.batch([mutation, oplogStmt], "write");
|
|
223
|
+
return event;
|
|
224
|
+
}
|
|
225
|
+
/** Build the hash-chained oplog INSERT statement for the next entry. */
|
|
226
|
+
async buildOplogInsert(op, scope, key, payload) {
|
|
227
|
+
const tail = await this.client.execute(`SELECT id, hash FROM oplog ORDER BY id DESC LIMIT 1`);
|
|
228
|
+
const tailRow = tail.rows[0];
|
|
229
|
+
const id = tailRow ? Number(tailRow.id) + 1 : 1;
|
|
230
|
+
const prev_hash = tailRow ? String(tailRow.hash) : GENESIS_PREV_HASH;
|
|
231
|
+
const ts = new Date().toISOString();
|
|
232
|
+
const hash = hashOplogEntry({ id, ts, op, scope, key, payload, prev_hash });
|
|
233
|
+
return {
|
|
234
|
+
sql: `INSERT INTO oplog (id, ts, op, scope, key, payload, prev_hash, hash)
|
|
235
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
236
|
+
args: [id, ts, op, scope, key, payload === null ? null : encode(payload), prev_hash, hash],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Chain writes so prev_hash reads never race a concurrent append. */
|
|
240
|
+
serializeWrite(work) {
|
|
241
|
+
const next = this.writeChain.then(work, work);
|
|
242
|
+
// Keep the chain alive even if `work` rejects, without swallowing the
|
|
243
|
+
// rejection that the caller awaits.
|
|
244
|
+
this.writeChain = next.then(() => undefined, () => undefined);
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
247
|
+
emit(event) {
|
|
248
|
+
for (const listener of this.listeners) {
|
|
249
|
+
try {
|
|
250
|
+
listener(event);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Listeners must not break the write path.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function encode(value) {
|
|
259
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
260
|
+
}
|
|
261
|
+
function decode(value) {
|
|
262
|
+
return JSON.parse(String(value));
|
|
263
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type MutationListener, type OplogEntry, type StateStore, type UpdateOp } from "./store.js";
|
|
2
|
+
export declare class StoreMemory implements StateStore {
|
|
3
|
+
private readonly store;
|
|
4
|
+
private readonly listeners;
|
|
5
|
+
private readonly oplog;
|
|
6
|
+
private nextOplogId;
|
|
7
|
+
get<T = unknown>(scope: string, key: string): Promise<T | null>;
|
|
8
|
+
set<T = unknown>(scope: string, key: string, value: T): Promise<T>;
|
|
9
|
+
update<T = unknown>(scope: string, key: string, ops: readonly UpdateOp[]): Promise<T>;
|
|
10
|
+
delete(scope: string, key: string): Promise<void>;
|
|
11
|
+
list<T = unknown>(scope: string): Promise<T[]>;
|
|
12
|
+
onMutation(listener: MutationListener): () => void;
|
|
13
|
+
readOplog(sinceId?: number): Promise<OplogEntry[]>;
|
|
14
|
+
verifyOplog(): Promise<{
|
|
15
|
+
ok: true;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
brokenAt: number;
|
|
19
|
+
}>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
private appendOplog;
|
|
22
|
+
private emit;
|
|
23
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
//
|
|
2
|
+
// In-memory StateStore: a two-level `Map<scope, Map<key, value>>` with an
|
|
3
|
+
// `update` method, mutation events, and an in-array hash-chained oplog. Used
|
|
4
|
+
// for parity tests against StoreLibsql and as a dependency-free fallback.
|
|
5
|
+
import { applyUpdateOps, } from "./store.js";
|
|
6
|
+
import { GENESIS_PREV_HASH, hashOplogEntry, verifyChain } from "./oplog.js";
|
|
7
|
+
/** Structured-clone a JSON value so callers can never mutate stored state. */
|
|
8
|
+
function clone(value) {
|
|
9
|
+
if (value === null || typeof value !== "object")
|
|
10
|
+
return value;
|
|
11
|
+
return JSON.parse(JSON.stringify(value));
|
|
12
|
+
}
|
|
13
|
+
export class StoreMemory {
|
|
14
|
+
store = new Map();
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
oplog = [];
|
|
17
|
+
nextOplogId = 1;
|
|
18
|
+
async get(scope, key) {
|
|
19
|
+
const value = this.store.get(scope)?.get(key);
|
|
20
|
+
return value === undefined ? null : clone(value);
|
|
21
|
+
}
|
|
22
|
+
async set(scope, key, value) {
|
|
23
|
+
const scopeMap = this.store.get(scope);
|
|
24
|
+
const old = scopeMap?.get(key);
|
|
25
|
+
const stored = clone(value);
|
|
26
|
+
if (scopeMap) {
|
|
27
|
+
scopeMap.set(key, stored);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.store.set(scope, new Map([[key, stored]]));
|
|
31
|
+
}
|
|
32
|
+
this.appendOplog("set", scope, key, stored);
|
|
33
|
+
this.emit({
|
|
34
|
+
scope,
|
|
35
|
+
key,
|
|
36
|
+
event_type: "set",
|
|
37
|
+
...(old === undefined ? {} : { old_value: clone(old) }),
|
|
38
|
+
new_value: clone(stored),
|
|
39
|
+
});
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
async update(scope, key, ops) {
|
|
43
|
+
const scopeMap = this.store.get(scope);
|
|
44
|
+
const existing = scopeMap?.get(key);
|
|
45
|
+
const base = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
46
|
+
? clone(existing)
|
|
47
|
+
: {};
|
|
48
|
+
const updated = applyUpdateOps(base, ops);
|
|
49
|
+
if (scopeMap) {
|
|
50
|
+
scopeMap.set(key, updated);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
this.store.set(scope, new Map([[key, updated]]));
|
|
54
|
+
}
|
|
55
|
+
this.appendOplog("update", scope, key, updated);
|
|
56
|
+
this.emit({
|
|
57
|
+
scope,
|
|
58
|
+
key,
|
|
59
|
+
event_type: "update",
|
|
60
|
+
...(existing === undefined ? {} : { old_value: clone(existing) }),
|
|
61
|
+
new_value: clone(updated),
|
|
62
|
+
});
|
|
63
|
+
return updated;
|
|
64
|
+
}
|
|
65
|
+
async delete(scope, key) {
|
|
66
|
+
const scopeMap = this.store.get(scope);
|
|
67
|
+
const old = scopeMap?.get(key);
|
|
68
|
+
const existed = scopeMap?.delete(key) ?? false;
|
|
69
|
+
// Idempotent: the original delete is unconditional and never errors, but
|
|
70
|
+
// we only log/emit when something actually changed to keep the oplog clean.
|
|
71
|
+
if (!existed)
|
|
72
|
+
return;
|
|
73
|
+
this.appendOplog("delete", scope, key, null);
|
|
74
|
+
this.emit({
|
|
75
|
+
scope,
|
|
76
|
+
key,
|
|
77
|
+
event_type: "delete",
|
|
78
|
+
...(old === undefined ? {} : { old_value: clone(old) }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async list(scope) {
|
|
82
|
+
const scopeMap = this.store.get(scope);
|
|
83
|
+
if (!scopeMap)
|
|
84
|
+
return [];
|
|
85
|
+
return Array.from(scopeMap.values(), (v) => clone(v));
|
|
86
|
+
}
|
|
87
|
+
onMutation(listener) {
|
|
88
|
+
this.listeners.add(listener);
|
|
89
|
+
return () => {
|
|
90
|
+
this.listeners.delete(listener);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async readOplog(sinceId) {
|
|
94
|
+
const cutoff = sinceId ?? 0;
|
|
95
|
+
return this.oplog.filter((e) => e.id > cutoff).map((e) => ({ ...e }));
|
|
96
|
+
}
|
|
97
|
+
async verifyOplog() {
|
|
98
|
+
const brokenAt = verifyChain(this.oplog);
|
|
99
|
+
return brokenAt === null ? { ok: true } : { ok: false, brokenAt };
|
|
100
|
+
}
|
|
101
|
+
async close() {
|
|
102
|
+
this.listeners.clear();
|
|
103
|
+
}
|
|
104
|
+
appendOplog(op, scope, key, payload) {
|
|
105
|
+
const id = this.nextOplogId++;
|
|
106
|
+
const ts = new Date().toISOString();
|
|
107
|
+
const prev_hash = this.oplog.length === 0 ? GENESIS_PREV_HASH : this.oplog[this.oplog.length - 1].hash;
|
|
108
|
+
const hash = hashOplogEntry({ id, ts, op, scope, key, payload, prev_hash });
|
|
109
|
+
this.oplog.push({ id, ts, op, scope, key, payload: clone(payload), prev_hash, hash });
|
|
110
|
+
}
|
|
111
|
+
emit(event) {
|
|
112
|
+
for (const listener of this.listeners) {
|
|
113
|
+
try {
|
|
114
|
+
listener(event);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Listeners must not break the write path.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** A single update operation. Only `type:"set"` is ever produced by callers. */
|
|
2
|
+
export interface UpdateOp {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly value?: unknown;
|
|
6
|
+
}
|
|
7
|
+
/** The kind of mutation an oplog entry / mutation event records. */
|
|
8
|
+
export type StateEventType = "set" | "update" | "delete";
|
|
9
|
+
/**
|
|
10
|
+
* Emitted by the store after a successful mutation. The kernel uses this to
|
|
11
|
+
* fire any registered type:"state" trigger whose scope matches `scope`
|
|
12
|
+
* (payload = {key, event_type, old_value, new_value}).
|
|
13
|
+
*
|
|
14
|
+
* For deletes, `new_value` is undefined. For sets/updates on a previously
|
|
15
|
+
* absent key, `old_value` is undefined.
|
|
16
|
+
*/
|
|
17
|
+
export interface StateMutationEvent {
|
|
18
|
+
readonly scope: string;
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly event_type: StateEventType;
|
|
21
|
+
readonly old_value?: unknown;
|
|
22
|
+
readonly new_value?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** Listener invoked synchronously-after-commit for every mutation. */
|
|
25
|
+
export type MutationListener = (event: StateMutationEvent) => void;
|
|
26
|
+
/**
|
|
27
|
+
* One append-only oplog record. The hash chain makes the log tamper-evident:
|
|
28
|
+
* `hash = sha256(canonical(id, ts, op, scope, key, payload, prev_hash))`.
|
|
29
|
+
* `prev_hash` is the hash of the immediately preceding entry, or the empty
|
|
30
|
+
* string for the genesis entry.
|
|
31
|
+
*/
|
|
32
|
+
export interface OplogEntry {
|
|
33
|
+
readonly id: number;
|
|
34
|
+
readonly ts: string;
|
|
35
|
+
readonly op: StateEventType;
|
|
36
|
+
readonly scope: string;
|
|
37
|
+
readonly key: string;
|
|
38
|
+
/** The post-mutation value (set/update) or null (delete). JSON value. */
|
|
39
|
+
readonly payload: unknown;
|
|
40
|
+
readonly prev_hash: string;
|
|
41
|
+
readonly hash: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The single persistence chokepoint. All five methods mirror the original
|
|
45
|
+
* StateKV semantics exactly. Implementations: StoreLibsql (durable, libSQL)
|
|
46
|
+
* and StoreMemory (in-process Map mirror, used for parity tests).
|
|
47
|
+
*/
|
|
48
|
+
export interface StateStore {
|
|
49
|
+
get<T = unknown>(scope: string, key: string): Promise<T | null>;
|
|
50
|
+
set<T = unknown>(scope: string, key: string, value: T): Promise<T>;
|
|
51
|
+
update<T = unknown>(scope: string, key: string, ops: readonly UpdateOp[]): Promise<T>;
|
|
52
|
+
delete(scope: string, key: string): Promise<void>;
|
|
53
|
+
list<T = unknown>(scope: string): Promise<T[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to mutation events. Returns an unsubscribe function. Listeners
|
|
56
|
+
* MUST NOT throw; the store wraps them defensively but a throwing listener
|
|
57
|
+
* only affects itself.
|
|
58
|
+
*/
|
|
59
|
+
onMutation(listener: MutationListener): () => void;
|
|
60
|
+
/** Read the append-only oplog in id order (optionally from `sinceId`, exclusive). */
|
|
61
|
+
readOplog(sinceId?: number): Promise<OplogEntry[]>;
|
|
62
|
+
/**
|
|
63
|
+
* Verify the oplog hash chain end to end. Returns the id of the first
|
|
64
|
+
* broken link, or null if the chain is intact (or empty).
|
|
65
|
+
*/
|
|
66
|
+
verifyOplog(): Promise<{
|
|
67
|
+
ok: true;
|
|
68
|
+
} | {
|
|
69
|
+
ok: false;
|
|
70
|
+
brokenAt: number;
|
|
71
|
+
}>;
|
|
72
|
+
/** Flush and release any resources (closes the libSQL client). Idempotent. */
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Apply the StateKV `update` op-list to a record, in place semantics returning
|
|
77
|
+
* the mutated record. Shared by both store implementations so their behavior
|
|
78
|
+
* is identical. Only `type:"set"` is honored (no push/inc/delete/append is
|
|
79
|
+
* ever produced by callers); `path` is a flat
|
|
80
|
+
* top-level field name, never dotted. Unknown op types are ignored.
|
|
81
|
+
*/
|
|
82
|
+
export declare function applyUpdateOps(current: Record<string, unknown>, ops: readonly UpdateOp[]): Record<string, unknown>;
|
|
83
|
+
/**
|
|
84
|
+
* Canonical JSON for hashing/signing: deterministic key ordering so the same
|
|
85
|
+
* logical value always produces the same bytes regardless of insertion order.
|
|
86
|
+
*/
|
|
87
|
+
export declare function canonicalize(value: unknown): string;
|