@pikku/kysely 0.12.17 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## 0.13.0
2
+
3
+ ### Minor Changes
4
+
5
+ - 241e6cf: Add `KyselyAuditService` — a durable `AuditService` that persists AuditEvents to an `audit` table via Kysely (the companion sink to `createAuditedKysely`). Its column mapping matches Fabric's platform audit-queue consumer, so a locally-run project and a deployed stage produce identical rows. Use it as the local/dev audit sink so audit events persist and are queryable without the platform queue.
6
+
7
+ ### Patch Changes
8
+
9
+ - 41ce2cb: Upgrade to TypeScript 6 and raise the minimum Node.js version to 22.
10
+
11
+ All packages now build against `typescript@^6.0.3` and declare `engines.node >= 22`. Internal tooling (`ts-json-schema-generator`, `zod-to-ts`) was bumped to TypeScript 6-compatible releases.
12
+
13
+ - Updated dependencies [41ce2cb]
14
+ - @pikku/core@0.12.44
15
+
16
+ ## 0.12.18
17
+
18
+ ### Patch Changes
19
+
20
+ - 5c0ff0f: Fix `getRunHistory` dropping step provenance (`fromStepName`). The value was persisted on the step row and used by the graph planner, but `getRunHistory` built its rows from the per-attempt history and never carried `fromStepName` through — so run history (and any timeline reconstructed from it) reported no predecessors. Redis and Kysely `getRunHistory` now return `fromStepName`. Also adds the missing `from_step_name` column (+ backfill) to the Kysely workflow mirror's `workflow_step` schema and persists it on mirror inserts, so a mirror-side history has identical provenance.
21
+ - 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
22
+
23
+ `getRunStatus` now returns `attempts` (the latest attempt count) per step, so
24
+ consumers can show retry counts without a second history query. It already
25
+ computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
26
+ workflow stores only stamped those timestamps on the _insert_ path — the
27
+ `running` / `succeeded` / `failed` status transitions updated the history row's
28
+ status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
29
+ was always undefined. The transitions now stamp the matching timestamp, so step
30
+ duration is populated for kysely- and mongodb-backed runs. (Redis already
31
+ stamped on transition.) A shared service-suite test guards both behaviours.
32
+
33
+ - Updated dependencies [4be205f]
34
+ - Updated dependencies [061c717]
35
+ - Updated dependencies [2c55e13]
36
+ - Updated dependencies [c745c26]
37
+ - Updated dependencies [57900b5]
38
+ - Updated dependencies [72694f6]
39
+ - @pikku/core@0.12.39
40
+
1
41
  ## 0.12.17
2
42
 
3
43
  ### Patch Changes
@@ -13,6 +13,7 @@ export { KyselyCredentialService } from './kysely-credential-service.js';
13
13
  export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js';
14
14
  export { KyselySessionStore } from './kysely-session-store.js';
15
15
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
16
+ export { KyselyAuditService } from './kysely-audit-service.js';
16
17
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
17
18
  export type { KyselyPikkuDB } from './kysely-tables.js';
18
19
  export type { WorkflowRunService } from '@pikku/core/workflow';
package/dist/src/index.js CHANGED
@@ -11,4 +11,5 @@ export { KyselySecretService } from './kysely-secret-service.js';
11
11
  export { KyselyCredentialService } from './kysely-credential-service.js';
12
12
  export { KyselySessionStore } from './kysely-session-store.js';
13
13
  export { createAuditedKysely, } from './create-audited-kysely.js';
14
+ export { KyselyAuditService } from './kysely-audit-service.js';
14
15
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
@@ -0,0 +1,19 @@
1
+ import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core';
2
+ import type { Kysely } from 'kysely';
3
+ /**
4
+ * Durable {@link AuditService} that persists AuditEvents to an `audit` table
5
+ * via Kysely — the companion sink to {@link createAuditedKysely}.
6
+ *
7
+ * The column mapping matches a platform audit-queue consumer, so a locally-run
8
+ * project and a deployed stage write identical rows (the read side relies only
9
+ * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
10
+ * `metadata`). The `audit` table is an optional per-project migration (not in
11
+ * the generated schema), so rows are inserted untyped; all columns are TEXT on
12
+ * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
13
+ */
14
+ export declare class KyselyAuditService implements AuditService {
15
+ private db;
16
+ constructor(db: Kysely<any>);
17
+ audit(event: AuditEvent): Promise<void>;
18
+ write(batch: AuditEventBatch): Promise<void>;
19
+ }
@@ -0,0 +1,55 @@
1
+ const jsonOrNull = (v) => v != null ? JSON.stringify(v) : null;
2
+ // No global `crypto` is guaranteed across every runtime, and audit_id is the
3
+ // PK — any collision is dropped by ON CONFLICT DO NOTHING.
4
+ const fallbackId = () => `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
5
+ /**
6
+ * Durable {@link AuditService} that persists AuditEvents to an `audit` table
7
+ * via Kysely — the companion sink to {@link createAuditedKysely}.
8
+ *
9
+ * The column mapping matches a platform audit-queue consumer, so a locally-run
10
+ * project and a deployed stage write identical rows (the read side relies only
11
+ * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
12
+ * `metadata`). The `audit` table is an optional per-project migration (not in
13
+ * the generated schema), so rows are inserted untyped; all columns are TEXT on
14
+ * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
15
+ */
16
+ export class KyselyAuditService {
17
+ db;
18
+ constructor(db) {
19
+ this.db = db;
20
+ }
21
+ async audit(event) {
22
+ await this.write([event]);
23
+ }
24
+ async write(batch) {
25
+ if (!batch.length)
26
+ return;
27
+ const rows = batch.map((e) => {
28
+ const metadata = e.metadata;
29
+ return {
30
+ audit_id: e.eventId ?? e.queryId ?? fallbackId(),
31
+ occurred_at: e.occurredAt ?? new Date().toISOString(),
32
+ type: e.type ?? 'unknown',
33
+ source: e.source ?? 'auto',
34
+ outcome: e.outcome ?? null,
35
+ function_id: e.functionId ?? null,
36
+ wire_type: e.wireType ?? null,
37
+ trace_id: e.traceId ?? null,
38
+ transaction_id: e.transactionId ?? null,
39
+ query_id: e.queryId ?? null,
40
+ actor_user_id: e.actor?.userId ?? null,
41
+ actor_org_id: e.actor?.orgId ?? null,
42
+ tables: jsonOrNull(metadata?.tables),
43
+ changed_cols: jsonOrNull(metadata?.changedColumns),
44
+ event: e.eventId ?? null,
45
+ old: null,
46
+ data: jsonOrNull(e.metadata),
47
+ };
48
+ });
49
+ await this.db
50
+ .insertInto('audit')
51
+ .values(rows)
52
+ .onConflict((oc) => oc.doNothing())
53
+ .execute();
54
+ }
55
+ }
@@ -61,6 +61,7 @@ export class KyselyWorkflowMirror {
61
61
  .addColumn('branch_taken', 'text')
62
62
  .addColumn('retries', 'integer')
63
63
  .addColumn('retry_delay', 'text')
64
+ .addColumn('from_step_name', 'text')
64
65
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
65
66
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
66
67
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -68,6 +69,14 @@ export class KyselyWorkflowMirror {
68
69
  'step_name',
69
70
  ])
70
71
  .execute();
72
+ // Additive: backfill the column on mirror tables created before from_step_name.
73
+ await this.db.schema
74
+ .alterTable('workflow_step')
75
+ .addColumn('from_step_name', 'text')
76
+ .execute()
77
+ .catch(() => {
78
+ // Column already exists (fresh tables include it above) — nothing to do.
79
+ });
71
80
  await this.db.schema
72
81
  .createTable('workflow_step_history')
73
82
  .ifNotExists()
@@ -144,6 +153,7 @@ export class KyselyWorkflowMirror {
144
153
  status: step.status,
145
154
  retries: step.retries ?? null,
146
155
  retryDelay: step.retryDelay?.toString() ?? null,
156
+ fromStepName: step.fromStepName ?? null,
147
157
  createdAt: now,
148
158
  updatedAt: now,
149
159
  })
@@ -137,6 +137,7 @@ export class KyselyWorkflowRunService {
137
137
  .select([
138
138
  's.workflowStepId',
139
139
  's.stepName',
140
+ 's.fromStepName',
140
141
  's.retries',
141
142
  's.retryDelay',
142
143
  'h.status',
@@ -158,6 +159,7 @@ export class KyselyWorkflowRunService {
158
159
  return {
159
160
  stepId,
160
161
  stepName: row.stepName,
162
+ fromStepName: row.fromStepName ?? undefined,
161
163
  status: row.status,
162
164
  result: parseJson(row.result),
163
165
  error: parseJson(row.error),
@@ -231,7 +231,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
231
231
  if (latestHistory) {
232
232
  await this.db
233
233
  .updateTable('workflowStepHistory')
234
- .set({ status: 'running' })
234
+ .set({ status: 'running', runningAt: new Date() })
235
235
  .where('historyId', '=', latestHistory.historyId)
236
236
  .execute();
237
237
  }
@@ -308,7 +308,11 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
308
308
  if (latestHistory) {
309
309
  await this.db
310
310
  .updateTable('workflowStepHistory')
311
- .set({ status: 'succeeded', result: resultJson })
311
+ .set({
312
+ status: 'succeeded',
313
+ result: resultJson,
314
+ succeededAt: new Date(),
315
+ })
312
316
  .where('historyId', '=', latestHistory.historyId)
313
317
  .execute();
314
318
  }
@@ -340,7 +344,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
340
344
  if (latestHistory) {
341
345
  await this.db
342
346
  .updateTable('workflowStepHistory')
343
- .set({ status: 'failed', error: errorJson })
347
+ .set({ status: 'failed', error: errorJson, failedAt: new Date() })
344
348
  .where('historyId', '=', latestHistory.historyId)
345
349
  .execute();
346
350
  }
@@ -1 +1 @@
1
- {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../bin/pikku-kysely-pure.ts"],"version":"5.9.3"}
1
+ {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.17",
3
+ "version": "0.13.0",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.38"
23
+ "@pikku/core": "^0.12.44"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.29.0"
@@ -31,7 +31,7 @@
31
31
  "kysely-codegen": "^0.20.0",
32
32
  "kysely-postgres-js": "^3.0.0",
33
33
  "postgres": "^3.4.8",
34
- "typescript": "^5.9"
34
+ "typescript": "^6.0.3"
35
35
  },
36
36
  "exports": {
37
37
  ".": "./dist/src/index.js"
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  createAuditedKysely,
17
17
  type CreateAuditedKyselyOptions,
18
18
  } from './create-audited-kysely.js'
19
+ export { KyselyAuditService } from './kysely-audit-service.js'
19
20
 
20
21
  export {
21
22
  SerializePlugin,
@@ -0,0 +1,62 @@
1
+ import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core'
2
+ import type { Kysely } from 'kysely'
3
+
4
+ const jsonOrNull = (v: unknown): string | null =>
5
+ v != null ? JSON.stringify(v) : null
6
+
7
+ // No global `crypto` is guaranteed across every runtime, and audit_id is the
8
+ // PK — any collision is dropped by ON CONFLICT DO NOTHING.
9
+ const fallbackId = (): string =>
10
+ `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
11
+
12
+ /**
13
+ * Durable {@link AuditService} that persists AuditEvents to an `audit` table
14
+ * via Kysely — the companion sink to {@link createAuditedKysely}.
15
+ *
16
+ * The column mapping matches a platform audit-queue consumer, so a locally-run
17
+ * project and a deployed stage write identical rows (the read side relies only
18
+ * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
19
+ * `metadata`). The `audit` table is an optional per-project migration (not in
20
+ * the generated schema), so rows are inserted untyped; all columns are TEXT on
21
+ * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
22
+ */
23
+ export class KyselyAuditService implements AuditService {
24
+ constructor(private db: Kysely<any>) {}
25
+
26
+ async audit(event: AuditEvent): Promise<void> {
27
+ await this.write([event])
28
+ }
29
+
30
+ async write(batch: AuditEventBatch): Promise<void> {
31
+ if (!batch.length) return
32
+ const rows = batch.map((e) => {
33
+ const metadata = e.metadata as
34
+ | { tables?: unknown; changedColumns?: unknown }
35
+ | undefined
36
+ return {
37
+ audit_id: e.eventId ?? e.queryId ?? fallbackId(),
38
+ occurred_at: e.occurredAt ?? new Date().toISOString(),
39
+ type: e.type ?? 'unknown',
40
+ source: e.source ?? 'auto',
41
+ outcome: e.outcome ?? null,
42
+ function_id: e.functionId ?? null,
43
+ wire_type: e.wireType ?? null,
44
+ trace_id: e.traceId ?? null,
45
+ transaction_id: e.transactionId ?? null,
46
+ query_id: e.queryId ?? null,
47
+ actor_user_id: e.actor?.userId ?? null,
48
+ actor_org_id: e.actor?.orgId ?? null,
49
+ tables: jsonOrNull(metadata?.tables),
50
+ changed_cols: jsonOrNull(metadata?.changedColumns),
51
+ event: e.eventId ?? null,
52
+ old: null,
53
+ data: jsonOrNull(e.metadata),
54
+ }
55
+ })
56
+ await (this.db as any)
57
+ .insertInto('audit')
58
+ .values(rows)
59
+ .onConflict((oc: any) => oc.doNothing())
60
+ .execute()
61
+ }
62
+ }
@@ -103,6 +103,29 @@ describe('KyselyWorkflowMirror — step lifecycle', () => {
103
103
  assert.equal(history[0]!.status, 'pending')
104
104
  })
105
105
 
106
+ test('getRunHistory surfaces step provenance (fromStepName)', async () => {
107
+ // The mirror persists fromStepName and getRunHistory reads it back, so the
108
+ // run history reconstructs the walked path (entry has none, the next step
109
+ // records its predecessor).
110
+ await seedStep(STEP_ID, 'begin')
111
+ await mirror.insertStepState(RUN_ID, {
112
+ stepId: `${STEP_ID}-2`,
113
+ stepName: 'next',
114
+ rpcName: 'rpc.fn',
115
+ data: { x: 2 },
116
+ status: 'pending',
117
+ attemptCount: 1,
118
+ fromStepName: 'begin',
119
+ createdAt: new Date(),
120
+ updatedAt: new Date(),
121
+ } as any)
122
+
123
+ const history = await runService.getRunHistory(RUN_ID)
124
+ const from = new Map(history.map((h) => [h.stepName, h.fromStepName]))
125
+ assert.equal(from.get('begin'), undefined, 'entry step has no predecessor')
126
+ assert.equal(from.get('next'), 'begin', 'next records its predecessor')
127
+ })
128
+
106
129
  test('running → succeeded updates the in-place history row', async () => {
107
130
  await seedStep()
108
131
  await mirror.setStepRunning(STEP_ID)
@@ -79,6 +79,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
79
79
  .addColumn('branch_taken', 'text')
80
80
  .addColumn('retries', 'integer')
81
81
  .addColumn('retry_delay', 'text')
82
+ .addColumn('from_step_name', 'text')
82
83
  .addColumn('created_at', 'timestamp', (col) =>
83
84
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
85
  )
@@ -91,6 +92,15 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
91
92
  ])
92
93
  .execute()
93
94
 
95
+ // Additive: backfill the column on mirror tables created before from_step_name.
96
+ await this.db.schema
97
+ .alterTable('workflow_step')
98
+ .addColumn('from_step_name', 'text')
99
+ .execute()
100
+ .catch(() => {
101
+ // Column already exists (fresh tables include it above) — nothing to do.
102
+ })
103
+
94
104
  await this.db.schema
95
105
  .createTable('workflow_step_history')
96
106
  .ifNotExists()
@@ -202,6 +212,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
202
212
  status: step.status,
203
213
  retries: step.retries ?? null,
204
214
  retryDelay: step.retryDelay?.toString() ?? null,
215
+ fromStepName: step.fromStepName ?? null,
205
216
  createdAt: now,
206
217
  updatedAt: now,
207
218
  })
@@ -192,6 +192,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
192
192
  .select([
193
193
  's.workflowStepId',
194
194
  's.stepName',
195
+ 's.fromStepName',
195
196
  's.retries',
196
197
  's.retryDelay',
197
198
  'h.status',
@@ -215,6 +216,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
215
216
  return {
216
217
  stepId,
217
218
  stepName: row.stepName,
219
+ fromStepName: row.fromStepName ?? undefined,
218
220
  status: row.status as StepState['status'],
219
221
  result: parseJson(row.result),
220
222
  error: parseJson(row.error),
@@ -319,7 +319,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
319
319
  if (latestHistory) {
320
320
  await this.db
321
321
  .updateTable('workflowStepHistory')
322
- .set({ status: 'running' })
322
+ .set({ status: 'running', runningAt: new Date() })
323
323
  .where('historyId', '=', latestHistory.historyId)
324
324
  .execute()
325
325
  }
@@ -417,7 +417,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
417
417
  if (latestHistory) {
418
418
  await this.db
419
419
  .updateTable('workflowStepHistory')
420
- .set({ status: 'succeeded', result: resultJson })
420
+ .set({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
421
421
  .where('historyId', '=', latestHistory.historyId)
422
422
  .execute()
423
423
  }
@@ -456,7 +456,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
456
456
  if (latestHistory) {
457
457
  await this.db
458
458
  .updateTable('workflowStepHistory')
459
- .set({ status: 'failed', error: errorJson })
459
+ .set({ status: 'failed', error: errorJson, failedAt: new Date() })
460
460
  .where('historyId', '=', latestHistory.historyId)
461
461
  .execute()
462
462
  }