@pikku/kysely 0.13.16 → 0.13.19

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/bin/pikku-kysely-pure.ts +0 -0
  3. package/dist/src/coercion-plugin.d.ts +41 -0
  4. package/dist/src/coercion-plugin.js +150 -0
  5. package/dist/src/create-audited-kysely.d.ts +1 -1
  6. package/dist/src/index.d.ts +4 -3
  7. package/dist/src/index.js +1 -0
  8. package/dist/src/kysely-agent-run-scores.d.ts +1 -1
  9. package/dist/src/kysely-agent-run-service.d.ts +1 -2
  10. package/dist/src/kysely-agent-run-state-service.d.ts +2 -2
  11. package/dist/src/kysely-agent-storage-service.d.ts +2 -2
  12. package/dist/src/kysely-audit-service.d.ts +3 -3
  13. package/dist/src/kysely-channel-store.d.ts +1 -1
  14. package/dist/src/kysely-credential-service.js +1 -1
  15. package/dist/src/kysely-deployment-service.d.ts +3 -2
  16. package/dist/src/kysely-deployment-service.js +2 -2
  17. package/dist/src/kysely-eventhub-store.d.ts +1 -1
  18. package/dist/src/kysely-eventhub-store.js +1 -1
  19. package/dist/src/kysely-scope-service.d.ts +3 -3
  20. package/dist/src/kysely-secret-service.d.ts +1 -1
  21. package/dist/src/kysely-secret-service.js +2 -2
  22. package/dist/src/kysely-session-store.d.ts +1 -1
  23. package/dist/src/kysely-tables.d.ts +1 -1
  24. package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
  25. package/dist/src/kysely-webhook-service.d.ts +1 -1
  26. package/dist/src/kysely-webhook-service.js +1 -1
  27. package/dist/src/kysely-workflow-mirror.d.ts +2 -2
  28. package/dist/src/kysely-workflow-run-service.d.ts +1 -1
  29. package/dist/src/kysely-workflow-service.d.ts +41 -10
  30. package/dist/src/kysely-workflow-service.js +75 -9
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +7 -7
  33. package/src/coercion-plugin.test.ts +160 -0
  34. package/src/coercion-plugin.ts +187 -0
  35. package/src/create-audited-kysely.test.ts +1 -1
  36. package/src/create-audited-kysely.ts +1 -1
  37. package/src/index.ts +9 -6
  38. package/src/kysely-agent-run-scores.ts +1 -1
  39. package/src/kysely-agent-run-service.ts +3 -2
  40. package/src/kysely-agent-run-state-service.ts +3 -3
  41. package/src/kysely-agent-storage-service.ts +7 -7
  42. package/src/kysely-audit-service.test.ts +1 -1
  43. package/src/kysely-audit-service.ts +4 -8
  44. package/src/kysely-channel-store.ts +1 -1
  45. package/src/kysely-credential-service.ts +5 -5
  46. package/src/kysely-deployment-service.ts +5 -4
  47. package/src/kysely-eventhub-store.ts +1 -1
  48. package/src/kysely-scope-service.ts +3 -3
  49. package/src/kysely-secret-service.ts +7 -6
  50. package/src/kysely-services.test.ts +1 -1
  51. package/src/kysely-session-store.ts +1 -1
  52. package/src/kysely-tables.ts +1 -1
  53. package/src/kysely-virtual-user-run-store.ts +1 -1
  54. package/src/kysely-webhook-service.test.ts +1 -1
  55. package/src/kysely-webhook-service.ts +2 -2
  56. package/src/kysely-workflow-mirror.ts +2 -2
  57. package/src/kysely-workflow-run-service.ts +1 -1
  58. package/src/kysely-workflow-service.test.ts +111 -0
  59. package/src/kysely-workflow-service.ts +108 -11
  60. package/dist/src/kysely-ai-agent-run-service.d.ts +0 -22
  61. package/dist/src/kysely-ai-agent-run-service.js +0 -190
  62. package/dist/src/kysely-ai-run-scores.d.ts +0 -10
  63. package/dist/src/kysely-ai-run-scores.js +0 -34
  64. package/dist/src/kysely-ai-run-state-service.d.ts +0 -24
  65. package/dist/src/kysely-ai-run-state-service.js +0 -169
  66. package/dist/src/kysely-ai-storage-service.d.ts +0 -40
  67. package/dist/src/kysely-ai-storage-service.js +0 -530
  68. package/dist/src/schema/ai.schema.d.ts +0 -12
  69. package/dist/src/schema/ai.schema.js +0 -102
@@ -1,6 +1,6 @@
1
- import type { SerializedError } from '@pikku/core/ecosystem/types';
1
+ import type { SerializedError } from '@pikku/core/errors';
2
2
  import { PikkuWorkflowService } from '@pikku/core/workflow';
3
- import type { WorkflowPlannedStep, WorkflowQueueOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/ecosystem/workflow';
3
+ import type { WorkflowPlannedStep, WorkflowQueueOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
4
4
  import { type Kysely } from 'kysely';
5
5
  import type { KyselyPikkuDB } from './kysely-tables.js';
6
6
  export declare class KyselyWorkflowService extends PikkuWorkflowService {
@@ -47,7 +47,36 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
47
47
  protected setStepErrorImpl(stepId: string, error: Error): Promise<void>;
48
48
  protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
49
49
  withRunLock<T>(_id: string, fn: () => Promise<T>): Promise<T>;
50
+ /**
51
+ * A pass-through, and deliberately so: the one decision that must exclude —
52
+ * claiming a step to execute it — is made by `claimStepForExecution` below as
53
+ * a single conditional write, which needs no lock to be exclusive.
54
+ *
55
+ * What is left are the suspend and approval sections in the engine, where the
56
+ * lock is genuine mutual exclusion rather than a claim. A dialect with a real
57
+ * primitive overrides this to cover them — `kysely-postgres` with
58
+ * `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
59
+ */
50
60
  withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
61
+ /**
62
+ * Claim the step with a status-guarded `UPDATE` and read the affected-row
63
+ * count: the database decides the winner in one statement, so two dispatches
64
+ * racing for the same step cannot both proceed.
65
+ *
66
+ * This replaces the read-then-write the base engine does under `withStepLock`,
67
+ * which is only as exclusive as that lock — and every dialect but Postgres and
68
+ * MySQL inherits a pass-through. A conditional update needs no advisory-lock
69
+ * primitive, so every SQL dialect gets the same guarantee.
70
+ *
71
+ * The winner then goes through the ordinary transition methods, so history
72
+ * rows and the mirror see exactly what they saw before.
73
+ */
74
+ protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
75
+ /**
76
+ * Move a step to `running` only if it is still in one of `from`, reporting
77
+ * whether this caller is the one that moved it.
78
+ */
79
+ private claimStepStatus;
51
80
  getCompletedGraphState(runId: string): Promise<{
52
81
  completedNodeIds: string[];
53
82
  failedNodeIds: string[];
@@ -81,16 +110,18 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
81
110
  private jsonPathFor;
82
111
  protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
83
112
  /**
84
- * The undispatched-step query, shared by the dialects that can safely relay.
113
+ * The undispatched-step query, shared by every dialect.
85
114
  *
86
- * Deliberately NOT an override of `findUndispatchedSteps`: this class's
87
- * `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
88
- * has no atomic claim, and the relay's redundant dispatches would become
89
- * double executions. A dialect opts in by overriding `findUndispatchedSteps`
90
- * to call this — which `kysely-postgres` and `kysely-mysql` do, having real
91
- * locks.
115
+ * It used to be deliberately NOT an override, because this class's
116
+ * `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
117
+ * had no atomic claim, so the relay's redundant dispatches would have become
118
+ * double executions, and only `kysely-postgres` and `kysely-mysql` opted in
119
+ * on the strength of their real locks. `claimStepForExecution` no longer
120
+ * needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
121
+ * dialect — so the relay is safe here for all of them and the override lives
122
+ * in the base.
92
123
  */
93
- protected queryUndispatchedSteps(before: Date, limit: number): Promise<Array<{
124
+ protected findUndispatchedSteps(before: Date, limit: number): Promise<Array<{
94
125
  runId: string;
95
126
  stepId: string;
96
127
  }>>;
@@ -1,4 +1,4 @@
1
- import { PikkuWorkflowService } from '@pikku/core/workflow';
1
+ import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
2
2
  import { sql } from 'kysely';
3
3
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
4
4
  import { parseJson } from './kysely-json.js';
@@ -363,9 +363,73 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
363
363
  async withRunLock(_id, fn) {
364
364
  return fn();
365
365
  }
366
+ /**
367
+ * A pass-through, and deliberately so: the one decision that must exclude —
368
+ * claiming a step to execute it — is made by `claimStepForExecution` below as
369
+ * a single conditional write, which needs no lock to be exclusive.
370
+ *
371
+ * What is left are the suspend and approval sections in the engine, where the
372
+ * lock is genuine mutual exclusion rather than a claim. A dialect with a real
373
+ * primitive overrides this to cover them — `kysely-postgres` with
374
+ * `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
375
+ */
366
376
  async withStepLock(_runId, _stepName, fn) {
367
377
  return fn();
368
378
  }
379
+ /**
380
+ * Claim the step with a status-guarded `UPDATE` and read the affected-row
381
+ * count: the database decides the winner in one statement, so two dispatches
382
+ * racing for the same step cannot both proceed.
383
+ *
384
+ * This replaces the read-then-write the base engine does under `withStepLock`,
385
+ * which is only as exclusive as that lock — and every dialect but Postgres and
386
+ * MySQL inherits a pass-through. A conditional update needs no advisory-lock
387
+ * primitive, so every SQL dialect gets the same guarantee.
388
+ *
389
+ * The winner then goes through the ordinary transition methods, so history
390
+ * rows and the mirror see exactly what they saw before.
391
+ */
392
+ async claimStepForExecution(runId, stepName, rpcName) {
393
+ const stepState = await this.getStepState(runId, stepName);
394
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
395
+ if (stepState.rpcName !== undefined &&
396
+ stepState.rpcName !== (rpcName ?? null)) {
397
+ throw new WorkflowStepFunctionMismatchError(runId, stepName);
398
+ }
399
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
400
+ return null;
401
+ }
402
+ if (stepState.status === 'failed') {
403
+ if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
404
+ return null;
405
+ }
406
+ return this.createRetryAttempt(stepState.stepId, 'running');
407
+ }
408
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
409
+ if (!(await this.claimStepStatus(stepState.stepId, [
410
+ 'pending',
411
+ 'scheduled',
412
+ ]))) {
413
+ return null;
414
+ }
415
+ await this.setStepRunning(stepState.stepId);
416
+ return { ...stepState, status: 'running' };
417
+ }
418
+ return stepState;
419
+ }
420
+ /**
421
+ * Move a step to `running` only if it is still in one of `from`, reporting
422
+ * whether this caller is the one that moved it.
423
+ */
424
+ async claimStepStatus(stepId, from) {
425
+ const claimed = await this.db
426
+ .updateTable('workflowStep')
427
+ .set({ status: 'running', updatedAt: new Date() })
428
+ .where('workflowStepId', '=', stepId)
429
+ .where('status', 'in', from)
430
+ .executeTakeFirst();
431
+ return Number(claimed?.numUpdatedRows ?? 0n) > 0;
432
+ }
369
433
  async getCompletedGraphState(runId) {
370
434
  const results = await this.db
371
435
  .selectFrom('workflowStep as ws')
@@ -470,16 +534,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
470
534
  .execute();
471
535
  }
472
536
  /**
473
- * The undispatched-step query, shared by the dialects that can safely relay.
537
+ * The undispatched-step query, shared by every dialect.
474
538
  *
475
- * Deliberately NOT an override of `findUndispatchedSteps`: this class's
476
- * `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
477
- * has no atomic claim, and the relay's redundant dispatches would become
478
- * double executions. A dialect opts in by overriding `findUndispatchedSteps`
479
- * to call this — which `kysely-postgres` and `kysely-mysql` do, having real
480
- * locks.
539
+ * It used to be deliberately NOT an override, because this class's
540
+ * `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
541
+ * had no atomic claim, so the relay's redundant dispatches would have become
542
+ * double executions, and only `kysely-postgres` and `kysely-mysql` opted in
543
+ * on the strength of their real locks. `claimStepForExecution` no longer
544
+ * needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
545
+ * dialect — so the relay is safe here for all of them and the override lives
546
+ * in the base.
481
547
  */
482
- async queryUndispatchedSteps(before, limit) {
548
+ async findUndispatchedSteps(before, limit) {
483
549
  const rows = await this.db
484
550
  .selectFrom('workflowStep as s')
485
551
  .innerJoin('workflowRuns as r', 'r.workflowRunId', 's.workflowRunId')
@@ -1 +1 @@
1
- {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-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-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
1
+ {"root":["../src/coercion-plugin.ts","../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-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-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.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.13.16",
3
+ "version": "0.13.19",
4
4
  "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -21,20 +21,20 @@
21
21
  "prepublishOnly": "yarn build"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pikku/core": "^0.12.84"
24
+ "@pikku/core": "^0.12.87"
25
25
  },
26
26
  "dependencies": {
27
- "kysely": "^0.29.0"
27
+ "kysely": "^0.29.5"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/better-sqlite3": "^7.6.13",
31
- "better-sqlite3": "^12.10.0",
30
+ "@types/better-sqlite3": "^9.6.0",
31
+ "better-sqlite3": "^13.0.3",
32
32
  "kysely-codegen": "^0.20.0",
33
33
  "kysely-postgres-js": "^3.0.0",
34
- "postgres": "^3.4.8",
34
+ "postgres": "^3.4.9",
35
35
  "typescript": "^6.0.3"
36
36
  },
37
37
  "exports": {
38
38
  ".": "./dist/src/index.js"
39
39
  }
40
- }
40
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { QueryResult, UnknownRow } from 'kysely'
4
+ import { createCoercionPlugin, type CoercionMap } from './coercion-plugin.js'
5
+
6
+ function selectFrom(...tables: string[]) {
7
+ return {
8
+ kind: 'SelectQueryNode',
9
+ from: {
10
+ kind: 'FromNode',
11
+ froms: tables.map((table) => ({
12
+ kind: 'TableNode',
13
+ table: {
14
+ kind: 'SchemableIdentifierNode',
15
+ identifier: { kind: 'IdentifierNode', name: table },
16
+ },
17
+ })),
18
+ },
19
+ }
20
+ }
21
+
22
+ const runTransform = async (
23
+ map: CoercionMap,
24
+ rows: UnknownRow[],
25
+ node?: unknown
26
+ ): Promise<UnknownRow[]> => {
27
+ const plugin = createCoercionPlugin({ map })
28
+ const queryId = {} as { queryId: string }
29
+ if (node !== undefined) {
30
+ plugin.transformQuery({ node, queryId } as any)
31
+ }
32
+ const result = { rows } as QueryResult<UnknownRow>
33
+ const out = await plugin.transformResult({ result, queryId } as any)
34
+ return out.rows as UnknownRow[]
35
+ }
36
+
37
+ describe('createCoercionPlugin', () => {
38
+ test('transformQuery passes the node through untouched', () => {
39
+ const plugin = createCoercionPlugin({ map: {} })
40
+ const node = selectFrom('users') as any
41
+ assert.equal(
42
+ plugin.transformQuery({ node, queryId: { queryId: 'x' } } as any),
43
+ node
44
+ )
45
+ })
46
+
47
+ test('coerces date columns from ISO strings to Date', async () => {
48
+ const [row] = await runTransform({ users: { created_at: 'date' } }, [
49
+ { created_at: '2026-06-26T00:00:00.000Z' },
50
+ ])
51
+ assert.ok(row!.created_at instanceof Date)
52
+ assert.equal(
53
+ (row!.created_at as Date).toISOString(),
54
+ '2026-06-26T00:00:00.000Z'
55
+ )
56
+ })
57
+
58
+ test('leaves unparseable date strings untouched', async () => {
59
+ const [row] = await runTransform({ users: { created_at: 'date' } }, [
60
+ { created_at: 'not-a-date' },
61
+ ])
62
+ assert.equal(row!.created_at, 'not-a-date')
63
+ })
64
+
65
+ test('coerces bool columns from 0/1 numbers and bigints', async () => {
66
+ const rows = await runTransform(
67
+ { users: { is_active: 'bool', has_pets: 'bool' } },
68
+ [{ is_active: 1, has_pets: 0n }]
69
+ )
70
+ assert.equal(rows[0]!.is_active, true)
71
+ assert.equal(rows[0]!.has_pets, false)
72
+ })
73
+
74
+ test('parses json columns and tolerates invalid json', async () => {
75
+ const rows = await runTransform(
76
+ { users: { meta: 'json', broken: 'json' } },
77
+ [{ meta: '{"a":1}', broken: '{bad' }]
78
+ )
79
+ assert.deepEqual(rows[0]!.meta, { a: 1 })
80
+ assert.equal(rows[0]!.broken, '{bad')
81
+ })
82
+
83
+ test('passes through null values and unmapped columns', async () => {
84
+ const rows = await runTransform({ users: { created_at: 'date' } }, [
85
+ { created_at: null, name: 'leave me' },
86
+ ])
87
+ assert.equal(rows[0]!.created_at, null)
88
+ assert.equal(rows[0]!.name, 'leave me')
89
+ })
90
+
91
+ test('matches both snake_case and camelCase column names', async () => {
92
+ const rows = await runTransform({ users: { created_at: 'date' } }, [
93
+ { createdAt: '2026-06-26T00:00:00.000Z' },
94
+ ])
95
+ assert.ok(rows[0]!.createdAt instanceof Date)
96
+ })
97
+
98
+ test('leaves columns ambiguous across tables uncoerced', async () => {
99
+ const rows = await runTransform(
100
+ { users: { value: 'json' }, events: { value: 'bool' } },
101
+ [{ value: '{"a":1}' }]
102
+ )
103
+ assert.equal(rows[0]!.value, '{"a":1}')
104
+ })
105
+
106
+ test('still coerces columns that agree across tables', async () => {
107
+ const rows = await runTransform(
108
+ { users: { active: 'bool' }, events: { active: 'bool' } },
109
+ [{ active: 1 }]
110
+ )
111
+ assert.equal(rows[0]!.active, true)
112
+ })
113
+
114
+ test('uses table-qualified coercions when names collide', async () => {
115
+ const map: CoercionMap = {
116
+ users: { settings: 'json' },
117
+ posts: { settings: 'bool' },
118
+ }
119
+ const fromUsers = await runTransform(
120
+ map,
121
+ [{ settings: '{"theme":"dark"}' }],
122
+ selectFrom('users')
123
+ )
124
+ assert.deepEqual(fromUsers[0]!.settings, { theme: 'dark' })
125
+
126
+ const fromPosts = await runTransform(
127
+ map,
128
+ [{ settings: 1 }],
129
+ selectFrom('posts')
130
+ )
131
+ assert.equal(fromPosts[0]!.settings, true)
132
+ })
133
+
134
+ test('table-qualified lookup matches camelCase result columns too', async () => {
135
+ const rows = await runTransform(
136
+ { users: { created_at: 'date' }, jobs: { created_at: 'json' } },
137
+ [{ createdAt: '2026-06-26T00:00:00.000Z' }],
138
+ selectFrom('users')
139
+ )
140
+ assert.ok(rows[0]!.createdAt instanceof Date)
141
+ })
142
+
143
+ test('a join whose tables disagree leaves the column uncoerced', async () => {
144
+ const rows = await runTransform(
145
+ { users: { value: 'json' }, events: { value: 'bool' } },
146
+ [{ value: '{"a":1}' }],
147
+ selectFrom('users', 'events')
148
+ )
149
+ assert.equal(rows[0]!.value, '{"a":1}')
150
+ })
151
+
152
+ test('falls back to flat keys when the query names no known table', async () => {
153
+ const rows = await runTransform(
154
+ { users: { created_at: 'date' } },
155
+ [{ createdAt: '2026-01-02T03:04:05.000Z' }],
156
+ selectFrom('unrelated')
157
+ )
158
+ assert.ok(rows[0]!.createdAt instanceof Date)
159
+ })
160
+ })
@@ -0,0 +1,187 @@
1
+ import type { KyselyPlugin, RootOperationNode, UnknownRow } from 'kysely'
2
+
3
+ /**
4
+ * The kinds a column can be coerced *to* at runtime. This is the value type of
5
+ * the generated `CoercionMap`, so it is a public artifact contract — a kind may
6
+ * only appear here if `fromDb` below actually does something with it.
7
+ *
8
+ * `uuid` is deliberately absent: it is an annotation kind (it selects the `Uuid`
9
+ * TypeScript type in the generated schema) but not a coercion kind, and the
10
+ * codegen filters it out of `coercion.gen.ts` for exactly that reason — a UUID
11
+ * is a string in both Postgres and SQLite.
12
+ */
13
+ export type ColumnKind = 'date' | 'bool' | 'json'
14
+
15
+ /**
16
+ * Per-table column kind map. Keys are the table names queries are written
17
+ * against; inner keys are snake_case column names.
18
+ * Generated into `outDir/db/coercion.gen.ts` by `pikku db migrate` from the
19
+ * explicit `kind` entries in `db/annotations.ts`.
20
+ */
21
+ export type CoercionMap = Record<string, Record<string, ColumnKind>>
22
+
23
+ export interface CreateCoercionPluginOptions {
24
+ map: CoercionMap
25
+ }
26
+
27
+ function fromDb(value: unknown, kind: ColumnKind): unknown {
28
+ if (value == null) return value
29
+ switch (kind) {
30
+ case 'date':
31
+ if (typeof value === 'string') {
32
+ const d = new Date(value)
33
+ return Number.isNaN(d.getTime()) ? value : d
34
+ }
35
+ return value
36
+ case 'bool':
37
+ if (typeof value === 'number') return value !== 0
38
+ if (typeof value === 'bigint') return value !== 0n
39
+ return value
40
+ case 'json':
41
+ if (typeof value !== 'string') return value
42
+ try {
43
+ return JSON.parse(value)
44
+ } catch {
45
+ return value
46
+ }
47
+ }
48
+ }
49
+
50
+ function snakeToCamel(name: string): string {
51
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
52
+ }
53
+
54
+ interface ResolvedMaps {
55
+ /** `table.column` → kind, for the exact per-query resolution. */
56
+ qualified: Record<string, ColumnKind>
57
+ /** `column` → kind, the fallback for when the query's tables say nothing. */
58
+ flat: Record<string, ColumnKind>
59
+ }
60
+
61
+ /**
62
+ * Flatten the per-table map into a table-qualified lookup plus a bare
63
+ * column-name fallback. Both are indexed by BOTH snake_case and camelCase so
64
+ * they work regardless of CamelCasePlugin ordering in the plugin array.
65
+ *
66
+ * When two tables disagree on the kind for the same column name, the bare name
67
+ * is ambiguous (a joined/aliased query could carry either) and is dropped from
68
+ * the flat map rather than letting whichever table was processed last win. The
69
+ * qualified map still resolves it whenever the query names its tables.
70
+ */
71
+ function buildMaps(map: CoercionMap): ResolvedMaps {
72
+ const qualified: Record<string, ColumnKind> = {}
73
+ const flat: Record<string, ColumnKind> = {}
74
+ const ambiguous = new Set<string>()
75
+ const assignFlat = (key: string, kind: ColumnKind) => {
76
+ const existing = flat[key]
77
+ if (existing !== undefined && existing !== kind) {
78
+ ambiguous.add(key)
79
+ return
80
+ }
81
+ flat[key] = kind
82
+ }
83
+ for (const [table, cols] of Object.entries(map)) {
84
+ for (const [col, kind] of Object.entries(cols)) {
85
+ const camel = snakeToCamel(col)
86
+ qualified[`${table}.${col}`] = kind
87
+ qualified[`${table}.${camel}`] = kind
88
+ assignFlat(col, kind)
89
+ assignFlat(camel, kind)
90
+ }
91
+ }
92
+ for (const key of ambiguous) delete flat[key]
93
+ return { qualified, flat }
94
+ }
95
+
96
+ /**
97
+ * Collect every table named anywhere in the query's operation node, so a result
98
+ * column can be resolved against the tables the query actually touched instead
99
+ * of against every table in the schema.
100
+ */
101
+ function collectQueryTables(node: unknown, out: Set<string>): void {
102
+ if (!node || typeof node !== 'object') return
103
+
104
+ const op = node as {
105
+ kind?: string
106
+ table?: { identifier?: { name?: string } }
107
+ }
108
+ if (op.kind === 'TableNode') {
109
+ const tableName = op.table?.identifier?.name
110
+ if (typeof tableName === 'string' && tableName.length > 0)
111
+ out.add(tableName)
112
+ }
113
+
114
+ for (const value of Object.values(node)) {
115
+ if (Array.isArray(value)) {
116
+ for (const item of value) collectQueryTables(item, out)
117
+ } else {
118
+ collectQueryTables(value, out)
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Resolve a result column's kind against the query's tables, falling back to
125
+ * the bare column name. Tables that disagree with each other are as ambiguous
126
+ * as two schema tables are, so they fall back too.
127
+ */
128
+ function lookupKind(
129
+ maps: ResolvedMaps,
130
+ tables: readonly string[],
131
+ col: string
132
+ ): ColumnKind | undefined {
133
+ let matchedKind: ColumnKind | undefined
134
+ for (const table of tables) {
135
+ const kind = maps.qualified[`${table}.${col}`]
136
+ if (!kind) continue
137
+ if (matchedKind && matchedKind !== kind) return maps.flat[col]
138
+ matchedKind = kind
139
+ }
140
+ return matchedKind ?? maps.flat[col]
141
+ }
142
+
143
+ /**
144
+ * Convert values stored in a dialect's on-disk representation back into the
145
+ * logical types app code expects (Date / boolean / parsed JSON).
146
+ *
147
+ * SQLite is what makes this necessary — it stores dates as TEXT, booleans as
148
+ * INTEGER and JSON as TEXT — but the plugin is dialect-neutral: on Postgres the
149
+ * driver already returns Date/boolean/object, and every branch of `fromDb`
150
+ * passes a non-string, non-number value straight through. That is why it lives
151
+ * in `@pikku/kysely` next to `SerializePlugin` rather than in a SQLite package:
152
+ * the CLI installs it on its local Postgres database too.
153
+ *
154
+ * Write-side coercion (Date → ISO string, boolean → 0/1, object → JSON) is
155
+ * handled in the SQLite database adapters (always-on), so this plugin is
156
+ * read-only.
157
+ *
158
+ * Place AFTER CamelCasePlugin in the plugin array (or it handles both
159
+ * orderings via the dual-keyed maps).
160
+ */
161
+ export function createCoercionPlugin(
162
+ options: CreateCoercionPluginOptions
163
+ ): KyselyPlugin {
164
+ const maps = buildMaps(options.map)
165
+ const queryTables = new WeakMap<object, readonly string[]>()
166
+ return {
167
+ transformQuery(args) {
168
+ const tables = new Set<string>()
169
+ collectQueryTables(args.node as RootOperationNode, tables)
170
+ queryTables.set(args.queryId, [...tables])
171
+ return args.node
172
+ },
173
+ async transformResult(args) {
174
+ const tables = queryTables.get(args.queryId) ?? []
175
+ const out: UnknownRow[] = []
176
+ for (const row of args.result.rows as UnknownRow[]) {
177
+ const next: UnknownRow = { ...row }
178
+ for (const [col, val] of Object.entries(row)) {
179
+ const kind = lookupKind(maps, tables, col)
180
+ if (kind) next[col] = fromDb(val, kind)
181
+ }
182
+ out.push(next)
183
+ }
184
+ return { ...args.result, rows: out }
185
+ },
186
+ }
187
+ }
@@ -7,7 +7,7 @@ import {
7
7
  NoopAuditService,
8
8
  type AuditEvent,
9
9
  type AuditService,
10
- } from '../../../core/src/index.js'
10
+ } from '@pikku/core/services'
11
11
 
12
12
  import { createAuditedKysely } from './create-audited-kysely.js'
13
13
 
@@ -1,4 +1,4 @@
1
- import type { AuditLog } from '@pikku/core'
1
+ import type { AuditLog } from '@pikku/core/services'
2
2
  import type {
3
3
  Kysely,
4
4
  KyselyPlugin,
package/src/index.ts CHANGED
@@ -25,6 +25,12 @@ export {
25
25
  type Serializer,
26
26
  type Deserializer,
27
27
  } from './serialize-plugin.js'
28
+ export {
29
+ createCoercionPlugin,
30
+ type ColumnKind,
31
+ type CoercionMap,
32
+ type CreateCoercionPluginOptions,
33
+ } from './coercion-plugin.js'
28
34
  export {
29
35
  auditSchema,
30
36
  pikkuSchemas,
@@ -40,12 +46,9 @@ export {
40
46
  } from './schema/index.js'
41
47
 
42
48
  export type { KyselyPikkuDB } from './kysely-tables.js'
43
- export type { WorkflowRunService } from '@pikku/core/ecosystem/workflow'
44
- export type {
45
- AgentRunService,
46
- AgentRunRow,
47
- } from '@pikku/core/ecosystem/agent'
49
+ export type { WorkflowRunService } from '@pikku/core/workflow'
50
+ export type { AgentRunService, AgentRunRow } from '@pikku/core/agent'
48
51
  export type {
49
52
  VirtualUserRunStore,
50
53
  VirtualUserRunRecord,
51
- } from '@pikku/core/ecosystem/virtual-user'
54
+ } from '@pikku/core/virtual-user'
@@ -1,7 +1,7 @@
1
1
  import type { Kysely } from 'kysely'
2
2
  import type { KyselyPikkuDB } from './kysely-tables.js'
3
- import type { SaveScoreInput } from '@pikku/core/services'
4
3
  import type { AgentRunScore } from '@pikku/core/agent-scorer'
4
+ import type { SaveScoreInput } from '@pikku/core/services'
5
5
  import { parseJson } from './kysely-json.js'
6
6
 
7
7
  /**
@@ -1,8 +1,9 @@
1
- import type { AgentThread, AgentMessage } from '@pikku/core/agent'
2
1
  import type {
2
+ AgentThread,
3
+ AgentMessage,
3
4
  AgentRunRow,
4
5
  AgentRunService,
5
- } from '@pikku/core/ecosystem/agent'
6
+ } from '@pikku/core/agent'
6
7
  import type { Kysely } from 'kysely'
7
8
  import { sql } from 'kysely'
8
9
  import type { KyselyPikkuDB } from './kysely-tables.js'
@@ -1,14 +1,14 @@
1
1
  import { sql } from 'kysely'
2
2
  import type { Kysely } from 'kysely'
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
+ import type { AgentRunState } from '@pikku/core/agent'
5
+ import type { AgentRunScore } from '@pikku/core/agent-scorer'
4
6
  import type {
5
7
  AgentRunStateService,
6
8
  CreateRunInput,
7
9
  SaveScoreInput,
8
10
  } from '@pikku/core/services'
9
- import type { AgentRunState } from '@pikku/core/agent'
10
- import type { PendingApproval } from '@pikku/core/ecosystem/agent'
11
- import type { AgentRunScore } from '@pikku/core/agent-scorer'
11
+ import type { PendingApproval } from '@pikku/core/agent'
12
12
  import { ensurePikkuSchema } from './schema/index.js'
13
13
  import { agentSchema } from './schema/agent.schema.js'
14
14
  import { getRunScores, saveRunScore } from './kysely-agent-run-scores.js'