@pikku/kysely 0.13.31 → 0.13.34

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 (38) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +12 -2
  3. package/dist/src/index.d.ts +2 -1
  4. package/dist/src/index.js +2 -1
  5. package/dist/src/kysely-lease-clock.d.ts +14 -0
  6. package/dist/src/kysely-lease-clock.js +14 -0
  7. package/dist/src/kysely-lease-service.d.ts +37 -0
  8. package/dist/src/kysely-lease-service.js +116 -0
  9. package/dist/src/kysely-tables.d.ts +16 -0
  10. package/dist/src/kysely-workflow-service.d.ts +36 -20
  11. package/dist/src/kysely-workflow-service.js +195 -102
  12. package/dist/src/schema/agent.schema.js +43 -36
  13. package/dist/src/schema/analytics.schema.js +17 -17
  14. package/dist/src/schema/audit.schema.js +22 -22
  15. package/dist/src/schema/channel.schema.js +13 -10
  16. package/dist/src/schema/credential.schema.js +6 -6
  17. package/dist/src/schema/deployment.schema.js +12 -12
  18. package/dist/src/schema/flag.schema.js +15 -12
  19. package/dist/src/schema/incoming-webhook.schema.js +7 -7
  20. package/dist/src/schema/index.d.ts +1 -0
  21. package/dist/src/schema/index.js +33 -7
  22. package/dist/src/schema/lease.schema.d.ts +2 -0
  23. package/dist/src/schema/lease.schema.js +12 -0
  24. package/dist/src/schema/pikku-schema.types.d.ts +42 -2
  25. package/dist/src/schema/pikku-schema.types.js +23 -7
  26. package/dist/src/schema/scope.schema.js +34 -22
  27. package/dist/src/schema/secret.schema.js +5 -5
  28. package/dist/src/schema/session.schema.js +3 -3
  29. package/dist/src/schema/trigger-source.schema.js +8 -8
  30. package/dist/src/schema/virtual-user-schedule.schema.js +4 -4
  31. package/dist/src/schema/virtual-user.schema.js +14 -14
  32. package/dist/src/schema/webhook.schema.js +17 -17
  33. package/dist/src/schema/workflow.schema.js +50 -49
  34. package/knowledge/decisions/a-lease-is-taken-by-one-guarded-update.md +30 -0
  35. package/knowledge/decisions/index.md +3 -0
  36. package/knowledge/decisions/leases-are-judged-by-the-database-clock.md +42 -0
  37. package/knowledge/decisions/workflows-own-the-lease-table-too.md +22 -0
  38. package/package.json +2 -2
@@ -1,14 +1,52 @@
1
- import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
1
+ import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, leaseAttemptsExhausted, } from '@pikku/core/workflow';
2
2
  import { sql } from 'kysely';
3
+ import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
3
4
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
4
5
  import { parseJson } from './kysely-json.js';
5
6
  import { requirePikkuSchema } from './schema/index.js';
6
7
  import { workflowSchema } from './schema/workflow.schema.js';
8
+ /**
9
+ * What a step read returns, one list for the single read and the whole-run
10
+ * read alike, so the replay snapshot cannot drift from `getStepState`.
11
+ */
12
+ const STEP_STATE_COLUMNS = [
13
+ 'workflowStepId',
14
+ 'status',
15
+ 'rpcName',
16
+ 'result',
17
+ 'error',
18
+ 'retries',
19
+ 'retryDelay',
20
+ 'fromStepName',
21
+ // `current_attempt` is bumped alongside every history insert, so it is
22
+ // already the count this used to aggregate — and reading it keeps the
23
+ // engine's hottest query off a table that grows for the life of the run.
24
+ 'currentAttempt',
25
+ 'leaseExpiresAt',
26
+ 'createdAt',
27
+ 'updatedAt',
28
+ ];
29
+ const toStepState = (row) => ({
30
+ stepId: row.workflowStepId,
31
+ status: row.status,
32
+ rpcName: row.rpcName ?? null,
33
+ result: parseJson(row.result),
34
+ error: parseJson(row.error),
35
+ attemptCount: Number(row.currentAttempt ?? 1),
36
+ retries: row.retries != null ? Number(row.retries) : undefined,
37
+ retryDelay: row.retryDelay ?? undefined,
38
+ fromStepName: row.fromStepName ?? undefined,
39
+ leaseExpiresAt: row.leaseExpiresAt != null
40
+ ? new Date(Number(row.leaseExpiresAt))
41
+ : undefined,
42
+ createdAt: new Date(row.createdAt),
43
+ updatedAt: new Date(row.updatedAt),
44
+ });
7
45
  export class KyselyWorkflowService extends PikkuWorkflowService {
8
46
  db;
9
47
  initialized = false;
10
48
  runService;
11
- constructor(db, options = {}) {
49
+ constructor(db, options) {
12
50
  super(options);
13
51
  this.db = db;
14
52
  this.runService = new KyselyWorkflowRunService(db);
@@ -92,73 +130,25 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
92
130
  async getStepState(runId, stepName) {
93
131
  const row = await this.db
94
132
  .selectFrom('workflowStep')
95
- .select([
96
- 'workflowStepId',
97
- 'status',
98
- 'rpcName',
99
- 'result',
100
- 'error',
101
- 'retries',
102
- 'retryDelay',
103
- 'fromStepName',
104
- // `current_attempt` is bumped alongside every history insert, so it is
105
- // already the count this used to aggregate — and reading it keeps the
106
- // engine's hottest query off a table that grows for the life of the run.
107
- 'currentAttempt',
108
- 'createdAt',
109
- 'updatedAt',
110
- ])
133
+ .select(STEP_STATE_COLUMNS)
111
134
  .where('workflowRunId', '=', runId)
112
135
  .where('stepName', '=', stepName)
113
136
  .executeTakeFirst();
114
137
  if (!row) {
115
138
  throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
116
139
  }
117
- return {
118
- stepId: row.workflowStepId,
119
- status: row.status,
120
- rpcName: row.rpcName ?? null,
121
- result: parseJson(row.result),
122
- error: parseJson(row.error),
123
- attemptCount: Number(row.currentAttempt ?? 1),
124
- retries: row.retries != null ? Number(row.retries) : undefined,
125
- retryDelay: row.retryDelay ?? undefined,
126
- fromStepName: row.fromStepName ?? undefined,
127
- createdAt: new Date(row.createdAt),
128
- updatedAt: new Date(row.updatedAt),
129
- };
140
+ return toStepState(row);
130
141
  }
131
142
  async listStepStates(runId) {
132
143
  const rows = await this.db
133
144
  .selectFrom('workflowStep')
134
- .select([
135
- 'workflowStepId',
136
- 'stepName',
137
- 'status',
138
- 'result',
139
- 'error',
140
- 'retries',
141
- 'retryDelay',
142
- 'fromStepName',
143
- 'currentAttempt',
144
- 'createdAt',
145
- 'updatedAt',
146
- ])
145
+ .select([...STEP_STATE_COLUMNS, 'stepName'])
147
146
  .where('workflowRunId', '=', runId)
148
147
  .execute();
149
- return rows.map((row) => ({
150
- stepId: row.workflowStepId,
151
- stepName: row.stepName,
152
- status: row.status,
153
- result: parseJson(row.result),
154
- error: parseJson(row.error),
155
- attemptCount: Number(row.currentAttempt ?? 1),
156
- retries: row.retries != null ? Number(row.retries) : undefined,
157
- retryDelay: row.retryDelay ?? undefined,
158
- fromStepName: row.fromStepName ?? undefined,
159
- createdAt: new Date(row.createdAt),
160
- updatedAt: new Date(row.updatedAt),
161
- }));
148
+ return rows.map((row) => ({ ...toStepState(row), stepName: row.stepName }));
149
+ }
150
+ async getRunSteps(runId) {
151
+ return this.runService.getRunSteps(runId);
162
152
  }
163
153
  async getRunHistory(runId) {
164
154
  return this.runService.getRunHistory(runId);
@@ -168,6 +158,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
168
158
  runningAt: new Date(),
169
159
  });
170
160
  }
161
+ async refreshStepLease(stepId, leaseMs, attempt) {
162
+ let query = this.db
163
+ .updateTable('workflowStep')
164
+ .set({
165
+ leaseExpiresAt: leaseMs === null ? null : leaseUntil(this.nowMs(), leaseMs),
166
+ updatedAt: new Date(),
167
+ })
168
+ .where('workflowStepId', '=', stepId);
169
+ if (attempt !== undefined) {
170
+ query = query.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
171
+ }
172
+ const renewed = await query.executeTakeFirst();
173
+ return Number(renewed?.numUpdatedRows ?? 0n) > 0;
174
+ }
175
+ /** Now, in epoch milliseconds, on the clock step leases are judged by. */
176
+ nowMs() {
177
+ return appNowMs();
178
+ }
171
179
  async setStepScheduledImpl(stepId) {
172
180
  await this.db
173
181
  .updateTable('workflowStep')
@@ -186,7 +194,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
186
194
  * over `created_at`, so a retry that lands in the same millisecond as the
187
195
  * attempt it replaces still resolves the newer row.
188
196
  */
189
- async writeStepTransition(stepId, status, historyValues) {
197
+ async writeStepTransition(stepId, status, historyValues, attempt) {
190
198
  const now = new Date();
191
199
  const stepValues = { status, updatedAt: now };
192
200
  if ('result' in historyValues)
@@ -199,7 +207,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
199
207
  if (status === 'failed')
200
208
  stepValues.result = null;
201
209
  await this.db.transaction().execute(async (trx) => {
202
- await trx
210
+ let step = trx
203
211
  .updateTable('workflowStep')
204
212
  .set({
205
213
  ...stepValues,
@@ -209,8 +217,17 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
209
217
  // MySQL would reject against the table being updated.
210
218
  currentAttempt: sql `coalesce(current_attempt, 1)`,
211
219
  })
212
- .where('workflowStepId', '=', stepId)
213
- .execute();
220
+ .where('workflowStepId', '=', stepId);
221
+ // Fenced to the attempt that made the write, so a dispatch whose step
222
+ // was claimed again after its lease lapsed cannot record over the newer
223
+ // attempt. Throwing rolls the transaction back.
224
+ if (attempt !== undefined) {
225
+ step = step.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
226
+ }
227
+ const moved = await step.executeTakeFirst();
228
+ if (attempt !== undefined && Number(moved?.numUpdatedRows ?? 0n) === 0) {
229
+ throw new WorkflowStepSupersededError(stepId, attempt);
230
+ }
214
231
  const written = await trx
215
232
  .updateTable('workflowStepHistory')
216
233
  .set({ status, ...historyValues })
@@ -247,7 +264,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
247
264
  }
248
265
  });
249
266
  }
250
- async insertHistoryRecord(stepId, status, attempt, result, error) {
267
+ async insertHistoryRecord(stepId, status, attempt, result, error, db = this.db) {
251
268
  const now = new Date();
252
269
  const values = {
253
270
  historyId: crypto.randomUUID(),
@@ -262,7 +279,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
262
279
  if (timestampField !== 'createdAt') {
263
280
  values[timestampField] = now;
264
281
  }
265
- await this.db
282
+ await db
266
283
  .insertInto('workflowStepHistory')
267
284
  .values(values)
268
285
  .execute();
@@ -291,13 +308,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
291
308
  .where('workflowStepId', '=', stepId)
292
309
  .execute();
293
310
  }
294
- async setStepResultImpl(stepId, result) {
311
+ async setStepResultImpl(stepId, result, attempt) {
295
312
  await this.writeStepTransition(stepId, 'succeeded', {
296
313
  result: JSON.stringify(result),
297
314
  succeededAt: new Date(),
298
- });
315
+ }, attempt);
299
316
  }
300
- async setStepErrorImpl(stepId, error) {
317
+ async setStepErrorImpl(stepId, error, attempt) {
301
318
  const serializedError = {
302
319
  message: error.message,
303
320
  stack: error.stack,
@@ -306,7 +323,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
306
323
  await this.writeStepTransition(stepId, 'failed', {
307
324
  error: JSON.stringify(serializedError),
308
325
  failedAt: new Date(),
309
- });
326
+ }, attempt);
310
327
  }
311
328
  async createRetryAttemptImpl(stepId, status) {
312
329
  // Read before writing: the new attempt number comes from the history this
@@ -329,6 +346,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
329
346
  .where('workflowStepId', '=', stepId)
330
347
  .execute();
331
348
  await this.insertHistoryRecord(stepId, status, attempt);
349
+ return this.readStepAttempt(stepId);
350
+ }
351
+ async readStepAttempt(stepId) {
332
352
  const row = await this.db
333
353
  .selectFrom('workflowStep')
334
354
  .select([
@@ -360,22 +380,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
360
380
  updatedAt: new Date(row.updatedAt),
361
381
  };
362
382
  }
363
- async withRunLock(_id, fn) {
364
- return fn();
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
- */
376
- async withStepLock(_runId, _stepName, fn) {
377
- return fn();
378
- }
379
383
  /**
380
384
  * Claim the step with a status-guarded `UPDATE` and read the affected-row
381
385
  * count: the database decides the winner in one statement, so two dispatches
@@ -388,28 +392,56 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
388
392
  *
389
393
  * The winner then goes through the ordinary transition methods, so history
390
394
  * rows and the mirror see exactly what they saw before.
395
+ *
396
+ * A `running` step is taken back only once its lease has lapsed, and the
397
+ * lease is part of the same guarded `UPDATE` — checking it here and writing
398
+ * it afterwards would let two dispatches read one lapsed lease and both
399
+ * proceed. Whether it has lapsed is the database's call, on its own clock:
400
+ * a live lease simply matches no row.
391
401
  */
392
- async claimStepForExecution(runId, stepName, rpcName) {
402
+ async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
393
403
  const stepState = await this.getStepState(runId, stepName);
394
404
  // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
395
405
  if (stepState.rpcName !== undefined &&
396
406
  stepState.rpcName !== (rpcName ?? null)) {
397
407
  throw new WorkflowStepFunctionMismatchError(runId, stepName);
398
408
  }
399
- if (stepState.status === 'succeeded' || stepState.status === 'running') {
409
+ if (stepState.status === 'succeeded') {
400
410
  return null;
401
411
  }
402
- if (stepState.status === 'failed') {
403
- if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
412
+ if (stepState.status === 'running') {
413
+ if (leaseAttemptsExhausted(stepState)) {
414
+ // Fail it only if its lease is still lapsed at the moment of writing:
415
+ // a worker that renewed since the read above still owns the step.
416
+ if (!(await this.claimStepStatus(stepState.stepId, ['running'], leaseMs))) {
417
+ return null;
418
+ }
419
+ await this.setStepError(stepState.stepId, new WorkflowStepLeaseExpiredError(runId, stepName, stepState.attemptCount), stepState.attemptCount);
420
+ // `executeWorkflowStepInner` reads a null claim as "another dispatch
421
+ // owns it" and returns without resuming, so the run would stay
422
+ // `running` until a stalled sweep. Requeue the orchestrator here.
423
+ await this.resumeWorkflow(runId);
404
424
  return null;
405
425
  }
406
- return this.createRetryAttempt(stepState.stepId, 'running');
426
+ }
427
+ // A failed step that has spent every attempt is settled, however it failed;
428
+ // a redelivered message must not buy it another one.
429
+ if (stepState.status === 'failed' && leaseAttemptsExhausted(stepState)) {
430
+ return null;
431
+ }
432
+ if (stepState.status === 'failed' || stepState.status === 'running') {
433
+ const attempt = stepState.attemptCount + 1;
434
+ return this.mirrored(() => this.reclaimStepAttempt(stepState, attempt, leaseMs), async (mirror, newStep) => {
435
+ if (newStep) {
436
+ await mirror.createRetryAttempt(stepState.stepId, {
437
+ ...newStep,
438
+ stepName,
439
+ });
440
+ }
441
+ });
407
442
  }
408
443
  if (stepState.status === 'pending' || stepState.status === 'scheduled') {
409
- if (!(await this.claimStepStatus(stepState.stepId, [
410
- 'pending',
411
- 'scheduled',
412
- ]))) {
444
+ if (!(await this.claimStepStatus(stepState.stepId, ['pending', 'scheduled'], leaseMs))) {
413
445
  return null;
414
446
  }
415
447
  await this.setStepRunning(stepState.stepId);
@@ -418,16 +450,62 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
418
450
  return stepState;
419
451
  }
420
452
  /**
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.
453
+ * Take back a failed or lapsed step as a new attempt. The attempt number is
454
+ * advanced by the same guarded `UPDATE` that grants the lease, so the worker
455
+ * it is taken from is fenced out of its step at once — deferring that to
456
+ * `createRetryAttempt` would leave a window in which its result still lands,
457
+ * only to be cleared and the step run again.
458
+ */
459
+ async reclaimStepAttempt(stepState, attempt, leaseMs) {
460
+ const now = this.nowMs();
461
+ const claimed = await this.db.transaction().execute(async (trx) => {
462
+ let query = trx
463
+ .updateTable('workflowStep')
464
+ .set({
465
+ status: 'running',
466
+ result: null,
467
+ error: null,
468
+ currentAttempt: attempt,
469
+ leaseExpiresAt: leaseUntil(now, leaseMs),
470
+ updatedAt: new Date(),
471
+ })
472
+ .where('workflowStepId', '=', stepState.stepId)
473
+ .where('status', '=', stepState.status)
474
+ .where(sql `coalesce(current_attempt, 1)`, '=', stepState.attemptCount);
475
+ if (stepState.status === 'running') {
476
+ query = query.where('leaseExpiresAt', '<', now);
477
+ }
478
+ const moved = await query.executeTakeFirst();
479
+ if (Number(moved?.numUpdatedRows ?? 0n) === 0)
480
+ return false;
481
+ await this.insertHistoryRecord(stepState.stepId, 'running', attempt, undefined, undefined, trx);
482
+ return true;
483
+ });
484
+ return claimed ? this.readStepAttempt(stepState.stepId) : null;
485
+ }
486
+ /**
487
+ * Move a step to `running` under a fresh lease, only if it is still in one of
488
+ * `from`, reporting whether this caller is the one that moved it.
489
+ *
490
+ * Claiming back a `running` step additionally requires its lease to have
491
+ * lapsed. A null lease never matches, which is what keeps a step parked on a
492
+ * child run — running, with no worker on it by design — out of reach.
423
493
  */
424
- async claimStepStatus(stepId, from) {
425
- const claimed = await this.db
494
+ async claimStepStatus(stepId, from, leaseMs) {
495
+ const now = this.nowMs();
496
+ let query = this.db
426
497
  .updateTable('workflowStep')
427
- .set({ status: 'running', updatedAt: new Date() })
498
+ .set({
499
+ status: 'running',
500
+ leaseExpiresAt: leaseUntil(now, leaseMs),
501
+ updatedAt: new Date(),
502
+ })
428
503
  .where('workflowStepId', '=', stepId)
429
- .where('status', 'in', from)
430
- .executeTakeFirst();
504
+ .where('status', 'in', from);
505
+ if (from.includes('running')) {
506
+ query = query.where('leaseExpiresAt', '<', now);
507
+ }
508
+ const claimed = await query.executeTakeFirst();
431
509
  return Number(claimed?.numUpdatedRows ?? 0n) > 0;
432
510
  }
433
511
  async getCompletedGraphState(runId) {
@@ -465,13 +543,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
465
543
  async getStepInstances(runId) {
466
544
  const rows = await this.db
467
545
  .selectFrom('workflowStep')
468
- .select(['stepName', 'status', 'fromStepName'])
546
+ .select(['stepName', 'status', 'fromStepName', 'leaseExpiresAt'])
469
547
  .where('workflowRunId', '=', runId)
470
548
  .execute();
471
549
  return rows.map((r) => ({
472
550
  stepName: r.stepName,
473
551
  status: r.status,
474
552
  fromStepName: r.fromStepName ?? undefined,
553
+ leaseExpiresAt: r.leaseExpiresAt != null
554
+ ? new Date(Number(r.leaseExpiresAt))
555
+ : undefined,
475
556
  }));
476
557
  }
477
558
  async getNodeResults(runId, nodeIds) {
@@ -537,7 +618,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
537
618
  * The undispatched-step query, shared by every dialect.
538
619
  *
539
620
  * It used to be deliberately NOT an override, because this class's
540
- * `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
621
+ * `withStepLock` was a pass-through: a subclass inheriting it (kysely-sqlite)
541
622
  * had no atomic claim, so the relay's redundant dispatches would have become
542
623
  * double executions, and only `kysely-postgres` and `kysely-mysql` opted in
543
624
  * on the strength of their real locks. `claimStepForExecution` no longer
@@ -571,7 +652,19 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
571
652
  .where(({ not, exists, selectFrom }) => not(exists(selectFrom('workflowStep as s')
572
653
  .select('s.workflowStepId')
573
654
  .whereRef('s.workflowRunId', '=', 'r.workflowRunId')
574
- .where('s.status', 'in', ['running', 'scheduled', 'suspended']))))
655
+ .where((eb) => eb.or([
656
+ eb('s.status', 'in', ['scheduled', 'suspended']),
657
+ // A `running` step counts as in flight only while someone
658
+ // still holds it. A lapsed lease is a dispatch that died, and
659
+ // a null one a step nothing was expected to hand back.
660
+ eb.and([
661
+ eb('s.status', '=', 'running'),
662
+ eb.or([
663
+ eb('s.leaseExpiresAt', 'is', null),
664
+ eb('s.leaseExpiresAt', '>', this.nowMs()),
665
+ ]),
666
+ ]),
667
+ ])))))
575
668
  // The run row only moves on a status change, so idleness has to hold for
576
669
  // the steps too — expressed as "no step touched since `before`" rather
577
670
  // than max(updated_at) so it stays one index-friendly NOT EXISTS.
@@ -13,89 +13,96 @@ export const agentSchema = {
13
13
  name: 'agent',
14
14
  ownedBy: ['agentStorage', 'agentRunState', 'agentRunService'],
15
15
  statements: [
16
- (db) => db.schema
16
+ (db, _types, ctx) => db.schema
17
17
  .createTable('agentThreads')
18
18
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
19
19
  .addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
20
- .addColumn('title', 'text')
21
- .addColumn('metadata', 'text')
20
+ .addColumn('title', ctx.text)
21
+ .addColumn('metadata', ctx.text)
22
22
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
23
23
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
24
- (db) => db.schema
24
+ (db, _types, ctx) => db.schema
25
25
  .createIndex('idx_agent_threads_resource')
26
26
  .on('agentThreads')
27
27
  .column('resourceId'),
28
- (db) => db.schema
28
+ (db, _types, ctx) => db.schema
29
29
  .createTable('agentMessage')
30
30
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
31
- .addColumn('threadId', 'varchar(36)', (col) => col.notNull().references('agentThreads.id').onDelete('cascade'))
31
+ .addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
32
32
  .addColumn('role', 'varchar(50)', (col) => col.notNull())
33
- .addColumn('content', 'text')
34
- .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
35
- (db) => db.schema
33
+ .addColumn('content', ctx.text)
34
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
35
+ .$call(ctx.foreignKeys('agentMessage', { threadId: 'agentThreads.id' })),
36
+ (db, _types, ctx) => db.schema
36
37
  .createIndex('idx_agent_message_thread')
37
38
  .on('agentMessage')
38
39
  .columns(['threadId', 'createdAt']),
39
- (db) => db.schema
40
+ (db, _types, ctx) => db.schema
40
41
  .createTable('agentToolCall')
41
42
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
42
- .addColumn('threadId', 'varchar(36)', (col) => col.notNull().references('agentThreads.id').onDelete('cascade'))
43
- .addColumn('messageId', 'varchar(36)', (col) => col.notNull().references('agentMessage.id').onDelete('cascade'))
43
+ .addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
44
+ .addColumn('messageId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentMessage.id'))
44
45
  .addColumn('runId', 'varchar(36)')
45
46
  .addColumn('toolName', 'varchar(255)', (col) => col.notNull())
46
- .addColumn('args', 'text', (col) => col.notNull())
47
- .addColumn('result', 'text')
47
+ .addColumn('args', ctx.text, (col) => col.notNull())
48
+ .addColumn('result', ctx.text)
48
49
  .addColumn('approvalStatus', 'varchar(50)')
49
50
  .addColumn('approvalType', 'varchar(50)')
50
51
  .addColumn('agentRunId', 'varchar(36)')
51
52
  .addColumn('displayToolName', 'varchar(255)')
52
- .addColumn('displayArgs', 'text')
53
- .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
54
- (db) => db.schema
53
+ .addColumn('displayArgs', ctx.text)
54
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
55
+ .$call(ctx.foreignKeys('agentToolCall', {
56
+ threadId: 'agentThreads.id',
57
+ messageId: 'agentMessage.id',
58
+ })),
59
+ (db, _types, ctx) => db.schema
55
60
  .createIndex('idx_agent_tool_call_thread')
56
61
  .on('agentToolCall')
57
62
  .column('threadId'),
58
- (db) => db.schema
63
+ (db, _types, ctx) => db.schema
59
64
  .createIndex('idx_agent_tool_call_message')
60
65
  .on('agentToolCall')
61
66
  .column('messageId'),
62
- (db) => db.schema
67
+ (db, _types, ctx) => db.schema
63
68
  .createTable('agentWorkingMemory')
64
69
  .addColumn('id', 'varchar(255)', (col) => col.notNull())
65
70
  .addColumn('scope', 'varchar(50)', (col) => col.notNull())
66
- .addColumn('data', 'text', (col) => col.notNull())
71
+ .addColumn('data', ctx.text, (col) => col.notNull())
67
72
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
68
73
  .addPrimaryKeyConstraint('agent_working_memory_pk', ['id', 'scope']),
69
- (db) => db.schema
74
+ (db, _types, ctx) => db.schema
70
75
  .createTable('agentRun')
71
76
  .addColumn('runId', 'varchar(36)', (col) => col.primaryKey())
72
77
  .addColumn('agentName', 'varchar(255)', (col) => col.notNull())
73
- .addColumn('threadId', 'varchar(36)', (col) => col.notNull().references('agentThreads.id').onDelete('cascade'))
78
+ .addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
74
79
  .addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
75
- .addColumn('status', 'varchar(50)', (col) => col.notNull().defaultTo('running'))
76
- .addColumn('errorMessage', 'text')
77
- .addColumn('suspendReason', 'text')
78
- .addColumn('missingRpcs', 'text')
79
- .addColumn('pendingApprovals', 'text')
80
+ .addColumn('status', 'varchar(50)', (col) => col.notNull().defaultTo(ctx.defaultText('running')))
81
+ .addColumn('errorMessage', ctx.text)
82
+ .addColumn('suspendReason', ctx.text)
83
+ .addColumn('missingRpcs', ctx.text)
84
+ .addColumn('pendingApprovals', ctx.text)
80
85
  .addColumn('usageInputTokens', 'integer', (col) => col.notNull().defaultTo(0))
81
86
  .addColumn('usageOutputTokens', 'integer', (col) => col.notNull().defaultTo(0))
82
- .addColumn('usageModel', 'varchar(255)', (col) => col.notNull().defaultTo(''))
87
+ .addColumn('usageModel', 'varchar(255)', (col) => col.notNull().defaultTo(ctx.defaultText('')))
83
88
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
84
- .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
85
- (db) => db.schema
89
+ .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
90
+ .$call(ctx.foreignKeys('agentRun', { threadId: 'agentThreads.id' })),
91
+ (db, _types, ctx) => db.schema
86
92
  .createIndex('idx_agent_run_thread')
87
93
  .on('agentRun')
88
94
  .columns(['threadId', 'createdAt']),
89
- (db) => db.schema
95
+ (db, _types, ctx) => db.schema
90
96
  .createTable('agentRunScore')
91
97
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
92
- .addColumn('runId', 'varchar(36)', (col) => col.notNull().references('agentRun.runId').onDelete('cascade'))
98
+ .addColumn('runId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentRun.runId'))
93
99
  .addColumn('scorerName', 'varchar(255)', (col) => col.notNull())
94
100
  .addColumn('score', 'real', (col) => col.notNull())
95
- .addColumn('reason', 'text')
96
- .addColumn('metadata', 'text')
97
- .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
98
- (db) => db.schema
101
+ .addColumn('reason', ctx.text)
102
+ .addColumn('metadata', ctx.text)
103
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
104
+ .$call(ctx.foreignKeys('agentRunScore', { runId: 'agentRun.runId' })),
105
+ (db, _types, ctx) => db.schema
99
106
  .createIndex('idx_agent_run_score_run')
100
107
  .on('agentRunScore')
101
108
  .columns(['runId', 'createdAt']),
@@ -17,30 +17,30 @@ export const analyticsSchema = {
17
17
  name: 'analytics',
18
18
  ownedBy: ['analyticsService'],
19
19
  statements: [
20
- (db) => db.schema
20
+ (db, _types, ctx) => db.schema
21
21
  .createTable('pikkuAnalyticsEvents')
22
- .addColumn('eventId', 'text', (col) => col.primaryKey())
23
- .addColumn('name', 'text', (col) => col.notNull())
24
- .addColumn('occurredAt', 'text', (col) => col.notNull())
25
- .addColumn('source', 'text', (col) => col.notNull())
26
- .addColumn('functionId', 'text')
27
- .addColumn('wireType', 'text')
28
- .addColumn('traceId', 'text')
29
- .addColumn('userId', 'text')
30
- .addColumn('orgId', 'text')
31
- .addColumn('pikkuUserId', 'text')
22
+ .addColumn('eventId', ctx.key, (col) => col.primaryKey())
23
+ .addColumn('name', ctx.key, (col) => col.notNull())
24
+ .addColumn('occurredAt', ctx.key, (col) => col.notNull())
25
+ .addColumn('source', ctx.text, (col) => col.notNull())
26
+ .addColumn('functionId', ctx.text)
27
+ .addColumn('wireType', ctx.text)
28
+ .addColumn('traceId', ctx.text)
29
+ .addColumn('userId', ctx.text)
30
+ .addColumn('orgId', ctx.text)
31
+ .addColumn('pikkuUserId', ctx.text)
32
32
  // The device id for a visitor with no session. Its own column beside
33
33
  // `userId` and not folded into it: the whole point of the anonymous id
34
34
  // is the traffic that has no user to be attributed to yet.
35
- .addColumn('anonymousId', 'text')
36
- .addColumn('vendorIds', 'text')
37
- .addColumn('consent', 'text')
38
- .addColumn('props', 'text'),
39
- (db) => db.schema
35
+ .addColumn('anonymousId', ctx.text)
36
+ .addColumn('vendorIds', ctx.text)
37
+ .addColumn('consent', ctx.text)
38
+ .addColumn('props', ctx.text),
39
+ (db, _types, ctx) => db.schema
40
40
  .createIndex('idx_pikku_analytics_events_occurred_at')
41
41
  .on('pikkuAnalyticsEvents')
42
42
  .column('occurredAt'),
43
- (db) => db.schema
43
+ (db, _types, ctx) => db.schema
44
44
  .createIndex('idx_pikku_analytics_events_name')
45
45
  .on('pikkuAnalyticsEvents')
46
46
  .column('name'),