@skein-js/storage-memory 0.2.1 → 0.4.0

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/dist/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
- import { SkeinStore, AssistantRepo, ThreadRepo, RunRepo, StoreRepo, Assistant, Thread, Run, RunKwargs, Item, RunEventBus, RunFrame, RunQueue, QueuedRun, RunProcessor, RunConsumerOptions, RunConsumer } from '@skein-js/core';
1
+ import { SkeinStore, StoreTtlConfig, AssistantRepo, ThreadRepo, RunRepo, StoreRepo, SkeinStoreSnapshot, RunEventBus, RunFrame, RunQueue, QueuedRun, RunProcessor, RunConsumerOptions, RunConsumer } from '@skein-js/core';
2
2
 
3
3
  /** In-process SkeinStore for development and tests. */
4
4
  declare class MemorySkeinStore implements SkeinStore {
5
5
  #private;
6
+ constructor(options?: {
7
+ ttl?: StoreTtlConfig;
8
+ });
6
9
  readonly assistants: AssistantRepo;
7
10
  readonly threads: ThreadRepo;
8
11
  readonly runs: RunRepo;
@@ -14,15 +17,21 @@ declare class MemorySkeinStore implements SkeinStore {
14
17
  snapshot(): MemoryStoreSnapshot;
15
18
  /** Replace all rows with a {@link snapshot}. Used by `skein dev` to restore persisted dev state. */
16
19
  hydrate(snapshot: MemoryStoreSnapshot): void;
20
+ /**
21
+ * Bulk-load rows from a snapshot, preserving ids + timestamps and *without* clearing what's
22
+ * already here: existing ids are left untouched (insert-if-absent). Unlike {@link hydrate} (a
23
+ * full replace for cross-restart persistence), this is the additive, lossless sink used by
24
+ * migration tooling — see `@skein-js/express`'s LangGraph importer. `loadSnapshotIntoStore`
25
+ * feature-detects this method, so the memory and Postgres drivers import identically.
26
+ */
27
+ restore(snapshot: SkeinStoreSnapshot): Promise<void>;
17
28
  }
18
- /** A JSON-serializable copy of a {@link MemorySkeinStore}'s rows. */
19
- interface MemoryStoreSnapshot {
20
- assistants: [string, Assistant][];
21
- threads: [string, Thread][];
22
- runs: [string, Run][];
23
- runKwargs: [string, RunKwargs][];
24
- items: [string, Item][];
25
- }
29
+ /**
30
+ * A JSON-serializable copy of a {@link MemorySkeinStore}'s rows. An alias of the driver-agnostic
31
+ * {@link SkeinStoreSnapshot} (identical shape); the name predates the shared type and is kept so
32
+ * the dev-persistence snapshot format reads the same.
33
+ */
34
+ type MemoryStoreSnapshot = SkeinStoreSnapshot;
26
35
 
27
36
  /**
28
37
  * In-memory FIFO of background runs, drained by a single-process consumer — all `skein dev` needs.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/memory-skein-store.ts
2
2
  import { randomUUID } from "crypto";
3
3
  import {
4
+ isMetadataSubset,
4
5
  isTerminalRunStatus,
5
6
  SkeinHttpError
6
7
  } from "@skein-js/core";
@@ -33,6 +34,37 @@ var MemorySkeinStore = class {
33
34
  // The opaque execution payload lives beside the run row (it is not part of the wire `Run`).
34
35
  #runKwargs = /* @__PURE__ */ new Map();
35
36
  #items = /* @__PURE__ */ new Map();
37
+ // Item expiry lives beside the item (never on the wire `Item`), keyed the same way as #items:
38
+ // `expiresAt` is epoch-ms (null = never expires), `ttlMinutes` is what a refresh-on-read extends by.
39
+ #itemExpiry = /* @__PURE__ */ new Map();
40
+ #ttl;
41
+ constructor(options) {
42
+ this.#ttl = options?.ttl;
43
+ }
44
+ /** Record (or clear) an item's expiry from a resolved per-item TTL in minutes. */
45
+ #setExpiry(id, ttlMinutes) {
46
+ if (ttlMinutes === null || ttlMinutes === void 0) {
47
+ this.#itemExpiry.delete(id);
48
+ return;
49
+ }
50
+ this.#itemExpiry.set(id, { expiresAt: Date.now() + ttlMinutes * 6e4, ttlMinutes });
51
+ }
52
+ /** True if the item has expired and should read as absent. */
53
+ #isExpired(id) {
54
+ const entry = this.#itemExpiry.get(id);
55
+ return entry?.expiresAt != null && entry.expiresAt <= Date.now();
56
+ }
57
+ /**
58
+ * Extend a live item's expiry on read when TTL is configured and `refresh_on_read` isn't disabled
59
+ * (it defaults on). With no configured TTL we never refresh, matching the Postgres driver.
60
+ */
61
+ #maybeRefresh(id) {
62
+ if (this.#ttl === void 0 || this.#ttl.refreshOnRead === false) return;
63
+ const entry = this.#itemExpiry.get(id);
64
+ if (entry?.ttlMinutes != null) {
65
+ this.#itemExpiry.set(id, { ...entry, expiresAt: Date.now() + entry.ttlMinutes * 6e4 });
66
+ }
67
+ }
36
68
  assistants = {
37
69
  list: async () => readAll(this.#assistants),
38
70
  get: async (assistantId) => readOne(this.#assistants, assistantId),
@@ -58,6 +90,22 @@ var MemorySkeinStore = class {
58
90
  };
59
91
  threads = {
60
92
  list: async () => readAll(this.#threads),
93
+ search: async (query) => {
94
+ const matched = readAll(this.#threads).filter(
95
+ (thread) => (!query.ids || query.ids.includes(thread.thread_id)) && (!query.status || thread.status === query.status) && isMetadataSubset(thread.metadata, query.metadata) && isMetadataSubset(thread.values, query.values)
96
+ );
97
+ const sortBy = query.sortBy ?? "created_at";
98
+ const direction = query.sortOrder === "asc" ? 1 : -1;
99
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
100
+ matched.sort((a, b) => {
101
+ const primary = compare(String(a[sortBy] ?? ""), String(b[sortBy] ?? ""));
102
+ const ordered = primary !== 0 ? primary : compare(a.thread_id, b.thread_id);
103
+ return direction * ordered;
104
+ });
105
+ const offset = query.offset ?? 0;
106
+ const limit = query.limit ?? matched.length;
107
+ return matched.slice(offset, offset + limit);
108
+ },
61
109
  get: async (threadId) => readOne(this.#threads, threadId),
62
110
  create: async (input) => {
63
111
  const at = nowIso();
@@ -88,6 +136,19 @@ var MemorySkeinStore = class {
88
136
  };
89
137
  return write(this.#threads, threadId, updated);
90
138
  },
139
+ copy: async (threadId) => {
140
+ const existing = this.#threads.get(threadId);
141
+ if (!existing) throw SkeinHttpError.notFound(`Thread "${threadId}" not found.`);
142
+ const at = nowIso();
143
+ const copy = {
144
+ ...clone(existing),
145
+ thread_id: randomUUID(),
146
+ created_at: at,
147
+ updated_at: at,
148
+ state_updated_at: at
149
+ };
150
+ return write(this.#threads, copy.thread_id, copy);
151
+ },
91
152
  delete: async (threadId) => {
92
153
  this.#threads.delete(threadId);
93
154
  for (const [runId, run] of this.#runs) {
@@ -139,10 +200,18 @@ var MemorySkeinStore = class {
139
200
  };
140
201
  store = {
141
202
  get: async (namespace, key) => {
142
- const found = this.#items.get(itemKey(namespace, key));
143
- return found ? clone(found) : null;
203
+ const id = itemKey(namespace, key);
204
+ const found = this.#items.get(id);
205
+ if (!found) return null;
206
+ if (this.#isExpired(id)) {
207
+ this.#items.delete(id);
208
+ this.#itemExpiry.delete(id);
209
+ return null;
210
+ }
211
+ this.#maybeRefresh(id);
212
+ return clone(found);
144
213
  },
145
- put: async (namespace, key, value) => {
214
+ put: async (namespace, key, value, options) => {
146
215
  const id = itemKey(namespace, key);
147
216
  const at = nowIso();
148
217
  const existing = this.#items.get(id);
@@ -155,14 +224,17 @@ var MemorySkeinStore = class {
155
224
  };
156
225
  const stored = clone(item);
157
226
  this.#items.set(id, stored);
227
+ this.#setExpiry(id, options?.ttl ?? this.#ttl?.defaultTtl ?? null);
158
228
  return clone(stored);
159
229
  },
160
230
  delete: async (namespace, key) => {
161
- this.#items.delete(itemKey(namespace, key));
231
+ const id = itemKey(namespace, key);
232
+ this.#items.delete(id);
233
+ this.#itemExpiry.delete(id);
162
234
  },
163
235
  search: async (query) => {
164
236
  const needle = query.query?.toLowerCase();
165
- const matches = [...this.#items.values()].filter((item) => hasPrefix(item.namespace, query.prefix)).filter(
237
+ const matches = [...this.#items.entries()].filter(([id]) => !this.#isExpired(id)).map(([, item]) => item).filter((item) => hasPrefix(item.namespace, query.prefix)).filter(
166
238
  (item) => needle ? JSON.stringify(item.value).toLowerCase().includes(needle) : true
167
239
  ).map((item) => {
168
240
  const result = clone(item);
@@ -174,12 +246,24 @@ var MemorySkeinStore = class {
174
246
  },
175
247
  listNamespaces: async (prefix) => {
176
248
  const seen = /* @__PURE__ */ new Map();
177
- for (const item of this.#items.values()) {
249
+ for (const [id, item] of this.#items.entries()) {
250
+ if (this.#isExpired(id)) continue;
178
251
  if (hasPrefix(item.namespace, prefix)) {
179
252
  seen.set(JSON.stringify(item.namespace), item.namespace);
180
253
  }
181
254
  }
182
255
  return [...seen.values()].map((namespace) => [...namespace]);
256
+ },
257
+ sweepExpired: async () => {
258
+ let removed = 0;
259
+ for (const id of [...this.#itemExpiry.keys()]) {
260
+ if (this.#isExpired(id)) {
261
+ this.#items.delete(id);
262
+ this.#itemExpiry.delete(id);
263
+ removed += 1;
264
+ }
265
+ }
266
+ return removed;
183
267
  }
184
268
  };
185
269
  /**
@@ -207,6 +291,24 @@ var MemorySkeinStore = class {
207
291
  fill(this.#runs, snapshot.runs);
208
292
  fill(this.#runKwargs, snapshot.runKwargs);
209
293
  fill(this.#items, snapshot.items);
294
+ this.#itemExpiry.clear();
295
+ }
296
+ /**
297
+ * Bulk-load rows from a snapshot, preserving ids + timestamps and *without* clearing what's
298
+ * already here: existing ids are left untouched (insert-if-absent). Unlike {@link hydrate} (a
299
+ * full replace for cross-restart persistence), this is the additive, lossless sink used by
300
+ * migration tooling — see `@skein-js/express`'s LangGraph importer. `loadSnapshotIntoStore`
301
+ * feature-detects this method, so the memory and Postgres drivers import identically.
302
+ */
303
+ async restore(snapshot) {
304
+ const add = (map, rows) => {
305
+ for (const [id, row] of rows) if (!map.has(id)) map.set(id, clone(row));
306
+ };
307
+ add(this.#assistants, snapshot.assistants);
308
+ add(this.#threads, snapshot.threads);
309
+ add(this.#runs, snapshot.runs);
310
+ add(this.#runKwargs, snapshot.runKwargs);
311
+ add(this.#items, snapshot.items);
210
312
  }
211
313
  };
212
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skein-js/storage-memory",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "In-memory SkeinStore + queue driver for development and tests.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Maina Wycliffe <wmmaina7@gmail.com>",
@@ -40,10 +40,10 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "dependencies": {
43
- "@skein-js/core": "0.2.1"
43
+ "@skein-js/core": "0.4.0"
44
44
  },
45
45
  "devDependencies": {
46
- "@skein-js/test-support": "0.2.1"
46
+ "@skein-js/test-support": "0.4.0"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public"