@kernl-sdk/pg 0.1.10 → 0.1.11

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.
@@ -1,5 +1,4 @@
1
-
2
- 
3
- > @kernl-sdk/pg@0.1.9 build /Users/andjones/Documents/projects/kernl/packages/storage/pg
4
- > tsc && tsc-alias --resolve-full-paths
5
-
1
+
2
+ > @kernl-sdk/pg@0.1.10 build /Users/andjones/Documents/projects/kernl/packages/storage/pg
3
+ > tsc && tsc-alias --resolve-full-paths
4
+
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @kernl/pg
2
2
 
3
+ ## 0.1.11
4
+
5
+ ### Patch Changes
6
+
7
+ - c5a5fcf: Storage now auto-initializes on first operation - no need to call init() manually
8
+ - Updated dependencies [c5a5fcf]
9
+ - kernl@0.6.2
10
+ - @kernl-sdk/storage@0.1.11
11
+
3
12
  ## 0.1.10
4
13
 
5
14
  ### Patch Changes
@@ -4,6 +4,86 @@ import { PGStorage } from "../storage.js";
4
4
  import { Agent, Context, } from "kernl";
5
5
  import { Thread } from "kernl/internal";
6
6
  const TEST_DB_URL = process.env.KERNL_PG_TEST_URL;
7
+ describe.sequential("PGStorage auto-initialization", () => {
8
+ if (!TEST_DB_URL) {
9
+ it.skip("requires KERNL_PG_TEST_URL to be set", () => { });
10
+ return;
11
+ }
12
+ /**
13
+ * Verifies that ALL store methods auto-initialize without explicit init() call.
14
+ *
15
+ * This is critical for DX - users should not need to remember to call init().
16
+ * Each method must internally call ensureInit() before any DB operation.
17
+ *
18
+ * Methods covered: get, list, insert, update, delete, history, append
19
+ */
20
+ it("auto-initializes on first store operation (no explicit init required)", async () => {
21
+ const pool = new Pool({ connectionString: TEST_DB_URL });
22
+ const storage = new PGStorage({ pool });
23
+ // Clean slate - drop schema to prove init runs automatically
24
+ await pool.query('DROP SCHEMA IF EXISTS "kernl" CASCADE');
25
+ // Bind minimal registries
26
+ const model = {
27
+ spec: "1.0",
28
+ provider: "test",
29
+ modelId: "auto-init-model",
30
+ };
31
+ const agent = new Agent({
32
+ id: "auto-init-agent",
33
+ name: "Auto Init Agent",
34
+ instructions: () => "test",
35
+ model,
36
+ });
37
+ const agents = new Map([["auto-init-agent", agent]]);
38
+ const models = new Map([
39
+ ["test/auto-init-model", model],
40
+ ]);
41
+ storage.bind({ agents, models });
42
+ const store = storage.threads;
43
+ const tid = "auto-init-thread";
44
+ // 1) list() - should auto-init
45
+ const threads = await store.list();
46
+ expect(threads).toEqual([]);
47
+ // 2) get() - should work (returns null for non-existent)
48
+ const got = await store.get(tid);
49
+ expect(got).toBeNull();
50
+ // 3) insert() - should work
51
+ const inserted = await store.insert({
52
+ id: tid,
53
+ namespace: "kernl",
54
+ agentId: "auto-init-agent",
55
+ model: "test/auto-init-model",
56
+ });
57
+ expect(inserted.tid).toBe(tid);
58
+ // 4) update() - should work
59
+ await store.update(tid, { tick: 1 });
60
+ const tickResult = await pool.query(`SELECT tick FROM "kernl"."threads" WHERE id = $1`, [tid]);
61
+ expect(tickResult.rows[0]?.tick).toBe(1);
62
+ // 5) history() - should work (empty)
63
+ const hist = await store.history(tid);
64
+ expect(hist).toEqual([]);
65
+ // 6) append() - should work
66
+ await store.append([
67
+ {
68
+ id: "evt-1",
69
+ tid,
70
+ seq: 0,
71
+ kind: "message",
72
+ timestamp: Date.now(),
73
+ data: { role: "user", text: "test" },
74
+ metadata: null,
75
+ },
76
+ ]);
77
+ // 7) delete() - should work
78
+ await store.delete(tid);
79
+ const afterDelete = await store.get(tid);
80
+ expect(afterDelete).toBeNull();
81
+ // Verify schema was created
82
+ const schemaResult = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'kernl'`);
83
+ expect(schemaResult.rows).toHaveLength(1);
84
+ await storage.close();
85
+ });
86
+ });
7
87
  describe.sequential("PGStorage integration", () => {
8
88
  if (!TEST_DB_URL) {
9
89
  it.skip("requires KERNL_PG_TEST_URL to be set", () => {
package/dist/storage.d.ts CHANGED
@@ -12,11 +12,25 @@ export interface PGStorageConfig {
12
12
  }
13
13
  /**
14
14
  * PostgreSQL storage adapter.
15
+ *
16
+ * Storage is lazily initialized on first use via `ensureInit()`. This means
17
+ * callers don't need to explicitly call `init()` - it happens automatically.
18
+ *
19
+ * NOTE: If the number of store methods grows significantly, consider replacing
20
+ * the manual `ensureInit()` calls with a Proxy-based wrapper for foolproof
21
+ * auto-initialization.
15
22
  */
16
23
  export declare class PGStorage implements KernlStorage {
17
24
  private pool;
25
+ private initPromise;
18
26
  threads: PGThreadStore;
19
27
  constructor(config: PGStorageConfig);
28
+ /**
29
+ * Ensure storage is initialized before any operation.
30
+ *
31
+ * Safe to call multiple times - initialization only runs once.
32
+ */
33
+ private ensureInit;
20
34
  /**
21
35
  * Bind runtime registries to storage.
22
36
  */
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAc,MAAM,IAAI,CAAC;AAK3C,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EACb,YAAY,EACZ,WAAW,EACZ,MAAM,OAAO,CAAC;AAIf,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAI/C;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;OAEG;IACH,IAAI,EAAE,IAAI,CAAC;CACZ;AAED;;GAEG;AACH,qBAAa,SAAU,YAAW,YAAY;IAC5C,OAAO,CAAC,IAAI,CAAO;IAEnB,OAAO,EAAE,aAAa,CAAC;gBAEX,MAAM,EAAE,eAAe;IAKnC;;OAEG;IACH,IAAI,CAAC,UAAU,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,aAAa,CAAA;KAAE,GAAG,IAAI;IAIxE;;OAEG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAIrE;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAyC9B;;OAEG;YACW,WAAW;IAUzB;;OAEG;YACW,YAAY;IA6F1B;;OAEG;YACW,UAAU;IAMxB;;OAEG;YACW,UAAU;IAIxB;;OAEG;YACW,SAAS;IAIvB;;OAEG;YACW,WAAW;CAkB1B"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAc,MAAM,IAAI,CAAC;AAG3C,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EACb,YAAY,EACZ,WAAW,EACZ,MAAM,OAAO,CAAC;AAMf,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAI/C;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;OAEG;IACH,IAAI,EAAE,IAAI,CAAC;CACZ;AAED;;;;;;;;;GASG;AACH,qBAAa,SAAU,YAAW,YAAY;IAC5C,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,WAAW,CAA8B;IAEjD,OAAO,EAAE,aAAa,CAAC;gBAEX,MAAM,EAAE,eAAe;IAKnC;;;;OAIG;YACW,UAAU;IAUxB;;OAEG;IACH,IAAI,CAAC,UAAU,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,aAAa,CAAA;KAAE,GAAG,IAAI;IAIxE;;OAEG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAIrE;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAyC9B;;OAEG;YACW,WAAW;IAUzB;;OAEG;YACW,YAAY;IA6F1B;;OAEG;YACW,UAAU;IAMxB;;OAEG;YACW,UAAU;IAIxB;;OAEG;YACW,SAAS;IAIvB;;OAEG;YACW,WAAW;CAkB1B"}
package/dist/storage.js CHANGED
@@ -3,17 +3,39 @@ import { SCHEMA_NAME, TABLE_MIGRATIONS } from "@kernl-sdk/storage";
3
3
  import { UnimplementedError } from "@kernl-sdk/shared/lib";
4
4
  /* pg */
5
5
  import { PGThreadStore } from "./thread/store.js";
6
- import { SQL_IDENTIFIER_REGEX } from "./sql.js";
7
6
  import { migrations } from "./migrations.js";
7
+ import { SQL_IDENTIFIER_REGEX } from "./sql.js";
8
8
  /**
9
9
  * PostgreSQL storage adapter.
10
+ *
11
+ * Storage is lazily initialized on first use via `ensureInit()`. This means
12
+ * callers don't need to explicitly call `init()` - it happens automatically.
13
+ *
14
+ * NOTE: If the number of store methods grows significantly, consider replacing
15
+ * the manual `ensureInit()` calls with a Proxy-based wrapper for foolproof
16
+ * auto-initialization.
10
17
  */
11
18
  export class PGStorage {
12
19
  pool;
20
+ initPromise = null;
13
21
  threads;
14
22
  constructor(config) {
15
23
  this.pool = config.pool;
16
- this.threads = new PGThreadStore(this.pool);
24
+ this.threads = new PGThreadStore(this.pool, () => this.ensureInit());
25
+ }
26
+ /**
27
+ * Ensure storage is initialized before any operation.
28
+ *
29
+ * Safe to call multiple times - initialization only runs once.
30
+ */
31
+ async ensureInit() {
32
+ if (!this.initPromise) {
33
+ this.initPromise = this.init().catch((err) => {
34
+ this.initPromise = null;
35
+ throw err;
36
+ });
37
+ }
38
+ return this.initPromise;
17
39
  }
18
40
  /**
19
41
  * Bind runtime registries to storage.
@@ -4,11 +4,15 @@ import { Thread, type ThreadEvent } from "kernl/internal";
4
4
  import { type AgentRegistry, type ModelRegistry, type ThreadStore, type NewThread, type ThreadUpdate, type ThreadInclude, type ThreadListOptions, type ThreadHistoryOptions } from "kernl";
5
5
  /**
6
6
  * PostgreSQL Thread store implementation.
7
+ *
8
+ * IMPORTANT: All async methods must call `await this.ensureInit()` before
9
+ * any database operations. This ensures schema/tables exist.
7
10
  */
8
11
  export declare class PGThreadStore implements ThreadStore {
9
12
  private db;
10
13
  private registries;
11
- constructor(db: Pool | PoolClient);
14
+ private ensureInit;
15
+ constructor(db: Pool | PoolClient, ensureInit: () => Promise<void>);
12
16
  /**
13
17
  * Bind runtime registries for hydrating Thread instances.
14
18
  *
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/thread/store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAE3C,OAAO,EAIL,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EAC1B,MAAM,OAAO,CAAC;AAEf;;GAEG;AACH,qBAAa,aAAc,YAAW,WAAW;IAC/C,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,UAAU,CAA0D;gBAEhE,EAAE,EAAE,IAAI,GAAG,UAAU;IAKjC;;;;OAIG;IACH,IAAI,CAAC,UAAU,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,aAAa,CAAA;KAAE,GAAG,IAAI;IAIxE;;OAEG;IACG,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAqGvE;;OAEG;IACG,IAAI,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAiG1D;;OAEG;IACG,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;IA0BhD;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IA6C/D;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMxC;;OAEG;IACG,OAAO,CACX,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,oBAAoB,GAC1B,OAAO,CAAC,WAAW,EAAE,CAAC;IAsCzB;;;;;;;;;OASG;IACG,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAkClD;;OAEG;IACH,OAAO,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,YAAY,CAAC;QAAC,MAAM,CAAC,EAAE,WAAW,EAAE,CAAA;KAAE,GAAG,MAAM;CAkC1E"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/thread/store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAE3C,OAAO,EAIL,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EAC1B,MAAM,OAAO,CAAC;AAEf;;;;;GAKG;AACH,qBAAa,aAAc,YAAW,WAAW;IAC/C,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,UAAU,CAA0D;IAC5E,OAAO,CAAC,UAAU,CAAsB;gBAE5B,EAAE,EAAE,IAAI,GAAG,UAAU,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAMlE;;;;OAIG;IACH,IAAI,CAAC,UAAU,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,aAAa,CAAA;KAAE,GAAG,IAAI;IAIxE;;OAEG;IACG,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAuGvE;;OAEG;IACG,IAAI,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAmG1D;;OAEG;IACG,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;IA4BhD;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IA+C/D;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQxC;;OAEG;IACG,OAAO,CACX,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,oBAAoB,GAC1B,OAAO,CAAC,WAAW,EAAE,CAAC;IAuCzB;;;;;;;;;OASG;IACG,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAmClD;;OAEG;IACH,OAAO,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,YAAY,CAAC;QAAC,MAAM,CAAC,EAAE,WAAW,EAAE,CAAA;KAAE,GAAG,MAAM;CAkC1E"}
@@ -4,12 +4,17 @@ import { Thread } from "kernl/internal";
4
4
  import { Context, } from "kernl";
5
5
  /**
6
6
  * PostgreSQL Thread store implementation.
7
+ *
8
+ * IMPORTANT: All async methods must call `await this.ensureInit()` before
9
+ * any database operations. This ensures schema/tables exist.
7
10
  */
8
11
  export class PGThreadStore {
9
12
  db;
10
13
  registries;
11
- constructor(db) {
14
+ ensureInit;
15
+ constructor(db, ensureInit) {
12
16
  this.db = db;
17
+ this.ensureInit = ensureInit;
13
18
  this.registries = null;
14
19
  }
15
20
  /**
@@ -24,6 +29,7 @@ export class PGThreadStore {
24
29
  * Get a thread by id.
25
30
  */
26
31
  async get(tid, include) {
32
+ await this.ensureInit();
27
33
  // JOIN with thread_events if include.history
28
34
  if (include?.history) {
29
35
  const opts = typeof include.history === "object" ? include.history : undefined;
@@ -110,6 +116,7 @@ export class PGThreadStore {
110
116
  * List threads matching the filter.
111
117
  */
112
118
  async list(options) {
119
+ await this.ensureInit();
113
120
  let query = `SELECT * FROM ${SCHEMA_NAME}.threads`;
114
121
  const values = [];
115
122
  let paramIndex = 1;
@@ -193,6 +200,7 @@ export class PGThreadStore {
193
200
  * Insert a new thread into the store.
194
201
  */
195
202
  async insert(thread) {
203
+ await this.ensureInit();
196
204
  const record = NewThreadCodec.encode(thread);
197
205
  const result = await this.db.query(`INSERT INTO ${SCHEMA_NAME}.threads
198
206
  (id, namespace, agent_id, model, context, tick, state, parent_task_id, metadata, created_at, updated_at)
@@ -216,6 +224,7 @@ export class PGThreadStore {
216
224
  * Update thread runtime state.
217
225
  */
218
226
  async update(tid, patch) {
227
+ await this.ensureInit();
219
228
  const updates = [];
220
229
  const values = [];
221
230
  let paramIndex = 1;
@@ -229,7 +238,7 @@ export class PGThreadStore {
229
238
  }
230
239
  if (patch.context !== undefined) {
231
240
  updates.push(`context = $${paramIndex++}`);
232
- // NOTE: Store the raw context value, not the Context wrapper.
241
+ // NOTE: Store the raw context value, not the Context wrapper.
233
242
  //
234
243
  // THis may change in the future depending on Context implementation.
235
244
  values.push(JSON.stringify(patch.context.context));
@@ -252,6 +261,7 @@ export class PGThreadStore {
252
261
  * Delete a thread and cascade to thread_events.
253
262
  */
254
263
  async delete(tid) {
264
+ await this.ensureInit();
255
265
  await this.db.query(`DELETE FROM ${SCHEMA_NAME}.threads WHERE id = $1`, [
256
266
  tid,
257
267
  ]);
@@ -260,6 +270,7 @@ export class PGThreadStore {
260
270
  * Get the event history for a thread.
261
271
  */
262
272
  async history(tid, opts) {
273
+ await this.ensureInit();
263
274
  let query = `SELECT * FROM ${SCHEMA_NAME}.thread_events WHERE tid = $1`;
264
275
  const values = [tid];
265
276
  let paramIndex = 2;
@@ -284,8 +295,7 @@ export class PGThreadStore {
284
295
  const result = await this.db.query(query, values);
285
296
  return result.rows.map((record) => ThreadEventRecordCodec.decode({
286
297
  ...record,
287
- // Normalize BIGINT (string) to number for zod schema
288
- timestamp: Number(record.timestamp),
298
+ timestamp: Number(record.timestamp), // normalize BIGINT (string) to number for zod schema
289
299
  }));
290
300
  }
291
301
  /**
@@ -301,6 +311,7 @@ export class PGThreadStore {
301
311
  async append(events) {
302
312
  if (events.length === 0)
303
313
  return;
314
+ await this.ensureInit();
304
315
  const records = events.map((e) => ThreadEventRecordCodec.encode(e));
305
316
  const values = [];
306
317
  const placeholders = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernl-sdk/pg",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "PostgreSQL storage adapter for kernl",
5
5
  "keywords": [
6
6
  "kernl",
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "pg": "^8.16.3",
42
- "kernl": "^0.6.1",
43
- "@kernl-sdk/storage": "0.1.10",
44
- "@kernl-sdk/shared": "^0.1.6"
42
+ "kernl": "^0.6.2",
43
+ "@kernl-sdk/shared": "^0.1.6",
44
+ "@kernl-sdk/storage": "0.1.11"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "tsc && tsc-alias --resolve-full-paths",
@@ -16,6 +16,107 @@ const TEST_DB_URL = process.env.KERNL_PG_TEST_URL;
16
16
  type TestLanguageModel =
17
17
  ModelRegistry extends { get(key: string): infer T | undefined } ? T : never;
18
18
 
19
+ describe.sequential("PGStorage auto-initialization", () => {
20
+ if (!TEST_DB_URL) {
21
+ it.skip("requires KERNL_PG_TEST_URL to be set", () => {});
22
+ return;
23
+ }
24
+
25
+ /**
26
+ * Verifies that ALL store methods auto-initialize without explicit init() call.
27
+ *
28
+ * This is critical for DX - users should not need to remember to call init().
29
+ * Each method must internally call ensureInit() before any DB operation.
30
+ *
31
+ * Methods covered: get, list, insert, update, delete, history, append
32
+ */
33
+ it("auto-initializes on first store operation (no explicit init required)", async () => {
34
+ const pool = new Pool({ connectionString: TEST_DB_URL });
35
+ const storage = new PGStorage({ pool });
36
+
37
+ // Clean slate - drop schema to prove init runs automatically
38
+ await pool.query('DROP SCHEMA IF EXISTS "kernl" CASCADE');
39
+
40
+ // Bind minimal registries
41
+ const model = {
42
+ spec: "1.0" as const,
43
+ provider: "test",
44
+ modelId: "auto-init-model",
45
+ } as unknown as TestLanguageModel;
46
+
47
+ const agent = new Agent({
48
+ id: "auto-init-agent",
49
+ name: "Auto Init Agent",
50
+ instructions: () => "test",
51
+ model,
52
+ });
53
+
54
+ const agents: AgentRegistry = new Map([["auto-init-agent", agent]]);
55
+ const models: ModelRegistry = new Map([
56
+ ["test/auto-init-model", model],
57
+ ]) as unknown as ModelRegistry;
58
+
59
+ storage.bind({ agents, models });
60
+ const store = storage.threads;
61
+ const tid = "auto-init-thread";
62
+
63
+ // 1) list() - should auto-init
64
+ const threads = await store.list();
65
+ expect(threads).toEqual([]);
66
+
67
+ // 2) get() - should work (returns null for non-existent)
68
+ const got = await store.get(tid);
69
+ expect(got).toBeNull();
70
+
71
+ // 3) insert() - should work
72
+ const inserted = await store.insert({
73
+ id: tid,
74
+ namespace: "kernl",
75
+ agentId: "auto-init-agent",
76
+ model: "test/auto-init-model",
77
+ });
78
+ expect(inserted.tid).toBe(tid);
79
+
80
+ // 4) update() - should work
81
+ await store.update(tid, { tick: 1 });
82
+ const tickResult = await pool.query<{ tick: number }>(
83
+ `SELECT tick FROM "kernl"."threads" WHERE id = $1`,
84
+ [tid],
85
+ );
86
+ expect(tickResult.rows[0]?.tick).toBe(1);
87
+
88
+ // 5) history() - should work (empty)
89
+ const hist = await store.history(tid);
90
+ expect(hist).toEqual([]);
91
+
92
+ // 6) append() - should work
93
+ await store.append([
94
+ {
95
+ id: "evt-1",
96
+ tid,
97
+ seq: 0,
98
+ kind: "message",
99
+ timestamp: new Date(),
100
+ data: { role: "user", text: "test" },
101
+ metadata: null,
102
+ } as any,
103
+ ]);
104
+
105
+ // 7) delete() - should work
106
+ await store.delete(tid);
107
+ const afterDelete = await store.get(tid);
108
+ expect(afterDelete).toBeNull();
109
+
110
+ // Verify schema was created
111
+ const schemaResult = await pool.query(
112
+ `SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'kernl'`,
113
+ );
114
+ expect(schemaResult.rows).toHaveLength(1);
115
+
116
+ await storage.close();
117
+ });
118
+ });
119
+
19
120
  describe.sequential("PGStorage integration", () => {
20
121
  if (!TEST_DB_URL) {
21
122
  it.skip("requires KERNL_PG_TEST_URL to be set", () => {
package/src/storage.ts CHANGED
@@ -2,20 +2,20 @@ import assert from "assert";
2
2
  import type { Pool, PoolClient } from "pg";
3
3
 
4
4
  /* workspace */
5
- import type { Table, Column, IndexConstraint } from "@kernl-sdk/storage";
6
- import { SCHEMA_NAME, TABLE_MIGRATIONS } from "@kernl-sdk/storage";
7
5
  import type {
8
6
  AgentRegistry,
9
7
  ModelRegistry,
10
8
  KernlStorage,
11
9
  Transaction,
12
10
  } from "kernl";
11
+ import type { Table, Column, IndexConstraint } from "@kernl-sdk/storage";
12
+ import { SCHEMA_NAME, TABLE_MIGRATIONS } from "@kernl-sdk/storage";
13
13
  import { UnimplementedError } from "@kernl-sdk/shared/lib";
14
14
 
15
15
  /* pg */
16
16
  import { PGThreadStore } from "./thread/store";
17
- import { SQL_IDENTIFIER_REGEX } from "./sql";
18
17
  import { migrations } from "./migrations";
18
+ import { SQL_IDENTIFIER_REGEX } from "./sql";
19
19
 
20
20
  /**
21
21
  * PostgreSQL storage configuration.
@@ -29,15 +29,38 @@ export interface PGStorageConfig {
29
29
 
30
30
  /**
31
31
  * PostgreSQL storage adapter.
32
+ *
33
+ * Storage is lazily initialized on first use via `ensureInit()`. This means
34
+ * callers don't need to explicitly call `init()` - it happens automatically.
35
+ *
36
+ * NOTE: If the number of store methods grows significantly, consider replacing
37
+ * the manual `ensureInit()` calls with a Proxy-based wrapper for foolproof
38
+ * auto-initialization.
32
39
  */
33
40
  export class PGStorage implements KernlStorage {
34
41
  private pool: Pool;
42
+ private initPromise: Promise<void> | null = null;
35
43
 
36
44
  threads: PGThreadStore;
37
45
 
38
46
  constructor(config: PGStorageConfig) {
39
47
  this.pool = config.pool;
40
- this.threads = new PGThreadStore(this.pool);
48
+ this.threads = new PGThreadStore(this.pool, () => this.ensureInit());
49
+ }
50
+
51
+ /**
52
+ * Ensure storage is initialized before any operation.
53
+ *
54
+ * Safe to call multiple times - initialization only runs once.
55
+ */
56
+ private async ensureInit(): Promise<void> {
57
+ if (!this.initPromise) {
58
+ this.initPromise = this.init().catch((err) => {
59
+ this.initPromise = null;
60
+ throw err;
61
+ });
62
+ }
63
+ return this.initPromise;
41
64
  }
42
65
 
43
66
  /**
@@ -23,13 +23,18 @@ import {
23
23
 
24
24
  /**
25
25
  * PostgreSQL Thread store implementation.
26
+ *
27
+ * IMPORTANT: All async methods must call `await this.ensureInit()` before
28
+ * any database operations. This ensures schema/tables exist.
26
29
  */
27
30
  export class PGThreadStore implements ThreadStore {
28
31
  private db: Pool | PoolClient;
29
32
  private registries: { agents: AgentRegistry; models: ModelRegistry } | null;
33
+ private ensureInit: () => Promise<void>;
30
34
 
31
- constructor(db: Pool | PoolClient) {
35
+ constructor(db: Pool | PoolClient, ensureInit: () => Promise<void>) {
32
36
  this.db = db;
37
+ this.ensureInit = ensureInit;
33
38
  this.registries = null;
34
39
  }
35
40
 
@@ -46,6 +51,8 @@ export class PGThreadStore implements ThreadStore {
46
51
  * Get a thread by id.
47
52
  */
48
53
  async get(tid: string, include?: ThreadInclude): Promise<Thread | null> {
54
+ await this.ensureInit();
55
+
49
56
  // JOIN with thread_events if include.history
50
57
  if (include?.history) {
51
58
  const opts =
@@ -150,6 +157,8 @@ export class PGThreadStore implements ThreadStore {
150
157
  * List threads matching the filter.
151
158
  */
152
159
  async list(options?: ThreadListOptions): Promise<Thread[]> {
160
+ await this.ensureInit();
161
+
153
162
  let query = `SELECT * FROM ${SCHEMA_NAME}.threads`;
154
163
  const values: any[] = [];
155
164
  let paramIndex = 1;
@@ -250,6 +259,8 @@ export class PGThreadStore implements ThreadStore {
250
259
  * Insert a new thread into the store.
251
260
  */
252
261
  async insert(thread: NewThread): Promise<Thread> {
262
+ await this.ensureInit();
263
+
253
264
  const record = NewThreadCodec.encode(thread);
254
265
 
255
266
  const result = await this.db.query<ThreadRecord>(
@@ -279,6 +290,8 @@ export class PGThreadStore implements ThreadStore {
279
290
  * Update thread runtime state.
280
291
  */
281
292
  async update(tid: string, patch: ThreadUpdate): Promise<Thread> {
293
+ await this.ensureInit();
294
+
282
295
  const updates: string[] = [];
283
296
  const values: any[] = [];
284
297
  let paramIndex = 1;
@@ -295,7 +308,7 @@ export class PGThreadStore implements ThreadStore {
295
308
 
296
309
  if (patch.context !== undefined) {
297
310
  updates.push(`context = $${paramIndex++}`);
298
- // NOTE: Store the raw context value, not the Context wrapper.
311
+ // NOTE: Store the raw context value, not the Context wrapper.
299
312
  //
300
313
  // THis may change in the future depending on Context implementation.
301
314
  values.push(JSON.stringify(patch.context.context));
@@ -327,6 +340,8 @@ export class PGThreadStore implements ThreadStore {
327
340
  * Delete a thread and cascade to thread_events.
328
341
  */
329
342
  async delete(tid: string): Promise<void> {
343
+ await this.ensureInit();
344
+
330
345
  await this.db.query(`DELETE FROM ${SCHEMA_NAME}.threads WHERE id = $1`, [
331
346
  tid,
332
347
  ]);
@@ -339,6 +354,8 @@ export class PGThreadStore implements ThreadStore {
339
354
  tid: string,
340
355
  opts?: ThreadHistoryOptions,
341
356
  ): Promise<ThreadEvent[]> {
357
+ await this.ensureInit();
358
+
342
359
  let query = `SELECT * FROM ${SCHEMA_NAME}.thread_events WHERE tid = $1`;
343
360
  const values: any[] = [tid];
344
361
  let paramIndex = 2;
@@ -370,8 +387,7 @@ export class PGThreadStore implements ThreadStore {
370
387
  return result.rows.map((record) =>
371
388
  ThreadEventRecordCodec.decode({
372
389
  ...record,
373
- // Normalize BIGINT (string) to number for zod schema
374
- timestamp: Number(record.timestamp),
390
+ timestamp: Number(record.timestamp), // normalize BIGINT (string) to number for zod schema
375
391
  } as ThreadEventRecord),
376
392
  );
377
393
  }
@@ -388,6 +404,7 @@ export class PGThreadStore implements ThreadStore {
388
404
  */
389
405
  async append(events: ThreadEvent[]): Promise<void> {
390
406
  if (events.length === 0) return;
407
+ await this.ensureInit();
391
408
 
392
409
  const records = events.map((e) => ThreadEventRecordCodec.encode(e));
393
410
 
@@ -434,6 +451,7 @@ export class PGThreadStore implements ThreadStore {
434
451
  const agent = this.registries.agents.get(record.agent_id);
435
452
  const model = this.registries.models.get(record.model);
436
453
 
454
+ // (TODO): we might want to allow this in the future, unclear how it would look though..
437
455
  if (!agent || !model) {
438
456
  throw new Error(
439
457
  `Thread ${record.id} references non-existent agent/model (agent: ${record.agent_id}, model: ${record.model})`,