@pikku/kysely 0.13.5 → 0.13.7

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 (35) hide show
  1. package/CHANGELOG.md +406 -0
  2. package/LICENSE +21 -0
  3. package/README.md +29 -2
  4. package/dist/src/kysely-credential-service.d.ts +3 -0
  5. package/dist/src/kysely-credential-service.js +50 -11
  6. package/dist/src/kysely-scope-service.d.ts +38 -0
  7. package/dist/src/kysely-scope-service.js +125 -2
  8. package/dist/src/kysely-secret-service.d.ts +3 -0
  9. package/dist/src/kysely-secret-service.js +50 -11
  10. package/dist/src/kysely-tables.d.ts +26 -2
  11. package/dist/src/kysely-workflow-mirror.js +37 -25
  12. package/dist/src/kysely-workflow-run-service.d.ts +0 -5
  13. package/dist/src/kysely-workflow-run-service.js +4 -16
  14. package/dist/src/kysely-workflow-service.d.ts +35 -7
  15. package/dist/src/kysely-workflow-service.js +194 -146
  16. package/dist/src/schema/credential.schema.js +5 -0
  17. package/dist/src/schema/scope.schema.js +12 -0
  18. package/dist/src/schema/secret.schema.js +5 -0
  19. package/dist/src/schema/workflow.schema.d.ts +4 -2
  20. package/dist/src/schema/workflow.schema.js +28 -2
  21. package/package.json +3 -2
  22. package/src/kysely-credential-service.ts +57 -12
  23. package/src/kysely-scope-service.ts +151 -2
  24. package/src/kysely-secret-service.ts +60 -12
  25. package/src/kysely-services.test.ts +123 -1
  26. package/src/kysely-tables.ts +29 -2
  27. package/src/kysely-workflow-mirror.test.ts +1 -13
  28. package/src/kysely-workflow-mirror.ts +44 -20
  29. package/src/kysely-workflow-run-service.ts +7 -20
  30. package/src/kysely-workflow-service.test.ts +431 -0
  31. package/src/kysely-workflow-service.ts +212 -178
  32. package/src/schema/credential.schema.ts +9 -0
  33. package/src/schema/scope.schema.ts +14 -0
  34. package/src/schema/secret.schema.ts +9 -0
  35. package/src/schema/workflow.schema.ts +38 -2
@@ -1,4 +1,5 @@
1
1
  import { PikkuWorkflowService, } from '@pikku/core/workflow';
2
+ import { sql } from 'kysely';
2
3
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
3
4
  import { parseJson } from './kysely-json.js';
4
5
  import { ensurePikkuSchema } from './schema/index.js';
@@ -68,11 +69,12 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
68
69
  retries: stepOptions?.retries ?? null,
69
70
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
70
71
  fromStepName: fromStepName ?? null,
72
+ currentAttempt: 1,
71
73
  createdAt: now,
72
74
  updatedAt: now,
73
75
  })
74
76
  .execute();
75
- await this.insertHistoryRecord(stepId, 'pending');
77
+ await this.insertHistoryRecord(stepId, 'pending', 1);
76
78
  return {
77
79
  stepId,
78
80
  status: 'pending',
@@ -88,25 +90,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
88
90
  }
89
91
  async getStepState(runId, stepName) {
90
92
  const row = await this.db
91
- .selectFrom('workflowStep as s')
93
+ .selectFrom('workflowStep')
92
94
  .select([
93
- 's.workflowStepId',
94
- 's.status',
95
- 's.result',
96
- 's.error',
97
- 's.retries',
98
- 's.retryDelay',
99
- 's.fromStepName',
100
- 's.createdAt',
101
- 's.updatedAt',
95
+ 'workflowStepId',
96
+ 'status',
97
+ 'result',
98
+ 'error',
99
+ 'retries',
100
+ 'retryDelay',
101
+ 'fromStepName',
102
+ // `current_attempt` is bumped alongside every history insert, so it is
103
+ // already the count this used to aggregate — and reading it keeps the
104
+ // engine's hottest query off a table that grows for the life of the run.
105
+ 'currentAttempt',
106
+ 'createdAt',
107
+ 'updatedAt',
102
108
  ])
103
- .select((eb) => eb
104
- .selectFrom('workflowStepHistory')
105
- .select(eb.fn.countAll().as('cnt'))
106
- .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
107
- .as('attemptCount'))
108
- .where('s.workflowRunId', '=', runId)
109
- .where('s.stepName', '=', stepName)
109
+ .where('workflowRunId', '=', runId)
110
+ .where('stepName', '=', stepName)
110
111
  .executeTakeFirst();
111
112
  if (!row) {
112
113
  throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
@@ -116,7 +117,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
116
117
  status: row.status,
117
118
  result: parseJson(row.result),
118
119
  error: parseJson(row.error),
119
- attemptCount: Number(row.attemptCount),
120
+ attemptCount: Number(row.currentAttempt ?? 1),
120
121
  retries: row.retries != null ? Number(row.retries) : undefined,
121
122
  retryDelay: row.retryDelay ?? undefined,
122
123
  fromStepName: row.fromStepName ?? undefined,
@@ -124,29 +125,45 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
124
125
  updatedAt: new Date(row.updatedAt),
125
126
  };
126
127
  }
128
+ async listStepStates(runId) {
129
+ const rows = await this.db
130
+ .selectFrom('workflowStep')
131
+ .select([
132
+ 'workflowStepId',
133
+ 'stepName',
134
+ 'status',
135
+ 'result',
136
+ 'error',
137
+ 'retries',
138
+ 'retryDelay',
139
+ 'fromStepName',
140
+ 'currentAttempt',
141
+ 'createdAt',
142
+ 'updatedAt',
143
+ ])
144
+ .where('workflowRunId', '=', runId)
145
+ .execute();
146
+ return rows.map((row) => ({
147
+ stepId: row.workflowStepId,
148
+ stepName: row.stepName,
149
+ status: row.status,
150
+ result: parseJson(row.result),
151
+ error: parseJson(row.error),
152
+ attemptCount: Number(row.currentAttempt ?? 1),
153
+ retries: row.retries != null ? Number(row.retries) : undefined,
154
+ retryDelay: row.retryDelay ?? undefined,
155
+ fromStepName: row.fromStepName ?? undefined,
156
+ createdAt: new Date(row.createdAt),
157
+ updatedAt: new Date(row.updatedAt),
158
+ }));
159
+ }
127
160
  async getRunHistory(runId) {
128
161
  return this.runService.getRunHistory(runId);
129
162
  }
130
163
  async setStepRunningImpl(stepId) {
131
- await this.db
132
- .updateTable('workflowStep')
133
- .set({ status: 'running', updatedAt: new Date() })
134
- .where('workflowStepId', '=', stepId)
135
- .execute();
136
- const latestHistory = await this.db
137
- .selectFrom('workflowStepHistory')
138
- .select('historyId')
139
- .where('workflowStepId', '=', stepId)
140
- .orderBy('createdAt', 'desc')
141
- .limit(1)
142
- .executeTakeFirst();
143
- if (latestHistory) {
144
- await this.db
145
- .updateTable('workflowStepHistory')
146
- .set({ status: 'running', runningAt: new Date() })
147
- .where('historyId', '=', latestHistory.historyId)
148
- .execute();
149
- }
164
+ await this.writeStepTransition(stepId, 'running', {
165
+ runningAt: new Date(),
166
+ });
150
167
  }
151
168
  async setStepScheduledImpl(stepId) {
152
169
  await this.db
@@ -155,12 +172,85 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
155
172
  .where('workflowStepId', '=', stepId)
156
173
  .execute();
157
174
  }
158
- async insertHistoryRecord(stepId, status, result, error) {
175
+ /**
176
+ * Move a step and its current history attempt to the same status in one
177
+ * transaction.
178
+ *
179
+ * Both halves matter: a crash between them used to leave the step row saying
180
+ * `succeeded` while its history row still said `running`, which silently
181
+ * corrupts `getRunHistory` and every timeline reconstruction built on it.
182
+ * The history row is targeted by its `attempt` rather than by a `LIMIT 1`
183
+ * over `created_at`, so a retry that lands in the same millisecond as the
184
+ * attempt it replaces still resolves the newer row.
185
+ */
186
+ async writeStepTransition(stepId, status, historyValues) {
187
+ const now = new Date();
188
+ const stepValues = { status, updatedAt: now };
189
+ if ('result' in historyValues)
190
+ stepValues.result = historyValues.result;
191
+ if ('error' in historyValues)
192
+ stepValues.error = historyValues.error;
193
+ // A step carries only its latest outcome, so the other half is cleared.
194
+ if (status === 'succeeded')
195
+ stepValues.error = null;
196
+ if (status === 'failed')
197
+ stepValues.result = null;
198
+ await this.db.transaction().execute(async (trx) => {
199
+ await trx
200
+ .updateTable('workflowStep')
201
+ .set({
202
+ ...stepValues,
203
+ // A step that somehow reaches a transition without a live attempt
204
+ // addresses no history row at all, so give it one here. Written as an
205
+ // expression over the row's own column rather than a subquery, which
206
+ // MySQL would reject against the table being updated.
207
+ currentAttempt: sql `coalesce(current_attempt, 1)`,
208
+ })
209
+ .where('workflowStepId', '=', stepId)
210
+ .execute();
211
+ const written = await trx
212
+ .updateTable('workflowStepHistory')
213
+ .set({ status, ...historyValues })
214
+ .where('workflowStepId', '=', stepId)
215
+ // Correlate through the step row rather than a MAX() over this table:
216
+ // MySQL rejects a subquery that reads the table being updated, and a
217
+ // primary-key lookup is cheaper than an aggregate anyway.
218
+ .where('attempt', '=', (eb) => eb
219
+ .selectFrom('workflowStep')
220
+ .select('currentAttempt')
221
+ .where('workflowStepId', '=', stepId))
222
+ .executeTakeFirst();
223
+ // Matching nothing means the attempt has no history row to move, which
224
+ // would commit the step's new status with no record of it — precisely
225
+ // the divergence this transaction exists to prevent. Write the missing
226
+ // row instead of letting the pair drift apart.
227
+ if (Number(written?.numUpdatedRows ?? 0n) === 0) {
228
+ const step = await trx
229
+ .selectFrom('workflowStep')
230
+ .select('currentAttempt')
231
+ .where('workflowStepId', '=', stepId)
232
+ .executeTakeFirst();
233
+ await trx
234
+ .insertInto('workflowStepHistory')
235
+ .values({
236
+ historyId: crypto.randomUUID(),
237
+ workflowStepId: stepId,
238
+ status,
239
+ attempt: step?.currentAttempt ?? 1,
240
+ createdAt: now,
241
+ ...historyValues,
242
+ })
243
+ .execute();
244
+ }
245
+ });
246
+ }
247
+ async insertHistoryRecord(stepId, status, attempt, result, error) {
159
248
  const now = new Date();
160
249
  const values = {
161
250
  historyId: crypto.randomUUID(),
162
251
  workflowStepId: stepId,
163
252
  status,
253
+ attempt,
164
254
  result: result != null ? JSON.stringify(result) : null,
165
255
  error: error != null ? JSON.stringify(error) : null,
166
256
  createdAt: now,
@@ -199,35 +289,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
199
289
  .execute();
200
290
  }
201
291
  async setStepResultImpl(stepId, result) {
202
- const resultJson = JSON.stringify(result);
203
- await this.db
204
- .updateTable('workflowStep')
205
- .set({
206
- status: 'succeeded',
207
- result: resultJson,
208
- error: null,
209
- updatedAt: new Date(),
210
- })
211
- .where('workflowStepId', '=', stepId)
212
- .execute();
213
- const latestHistory = await this.db
214
- .selectFrom('workflowStepHistory')
215
- .select('historyId')
216
- .where('workflowStepId', '=', stepId)
217
- .orderBy('createdAt', 'desc')
218
- .limit(1)
219
- .executeTakeFirst();
220
- if (latestHistory) {
221
- await this.db
222
- .updateTable('workflowStepHistory')
223
- .set({
224
- status: 'succeeded',
225
- result: resultJson,
226
- succeededAt: new Date(),
227
- })
228
- .where('historyId', '=', latestHistory.historyId)
229
- .execute();
230
- }
292
+ await this.writeStepTransition(stepId, 'succeeded', {
293
+ result: JSON.stringify(result),
294
+ succeededAt: new Date(),
295
+ });
231
296
  }
232
297
  async setStepErrorImpl(stepId, error) {
233
298
  const serializedError = {
@@ -235,65 +300,54 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
235
300
  stack: error.stack,
236
301
  code: error.code,
237
302
  };
238
- const errorJson = JSON.stringify(serializedError);
303
+ await this.writeStepTransition(stepId, 'failed', {
304
+ error: JSON.stringify(serializedError),
305
+ failedAt: new Date(),
306
+ });
307
+ }
308
+ async createRetryAttemptImpl(stepId, status) {
309
+ // Read before writing: the new attempt number comes from the history this
310
+ // step already has, and the row it reads is the one returned below.
311
+ const previous = await this.db
312
+ .selectFrom('workflowStep')
313
+ .select('currentAttempt')
314
+ .where('workflowStepId', '=', stepId)
315
+ .executeTakeFirst();
316
+ const attempt = (previous?.currentAttempt ?? 0) + 1;
239
317
  await this.db
240
318
  .updateTable('workflowStep')
241
319
  .set({
242
- status: 'failed',
243
- error: errorJson,
320
+ status,
244
321
  result: null,
322
+ error: null,
323
+ currentAttempt: attempt,
245
324
  updatedAt: new Date(),
246
325
  })
247
326
  .where('workflowStepId', '=', stepId)
248
327
  .execute();
249
- const latestHistory = await this.db
250
- .selectFrom('workflowStepHistory')
251
- .select('historyId')
252
- .where('workflowStepId', '=', stepId)
253
- .orderBy('createdAt', 'desc')
254
- .limit(1)
255
- .executeTakeFirst();
256
- if (latestHistory) {
257
- await this.db
258
- .updateTable('workflowStepHistory')
259
- .set({ status: 'failed', error: errorJson, failedAt: new Date() })
260
- .where('historyId', '=', latestHistory.historyId)
261
- .execute();
262
- }
263
- }
264
- async createRetryAttemptImpl(stepId, status) {
265
- await this.db
266
- .updateTable('workflowStep')
267
- .set({ status, result: null, error: null, updatedAt: new Date() })
268
- .where('workflowStepId', '=', stepId)
269
- .execute();
270
- await this.insertHistoryRecord(stepId, status);
328
+ await this.insertHistoryRecord(stepId, status, attempt);
271
329
  const row = await this.db
272
- .selectFrom('workflowStep as s')
330
+ .selectFrom('workflowStep')
273
331
  .select([
274
- 's.workflowStepId',
275
- 's.status',
276
- 's.result',
277
- 's.error',
278
- 's.retries',
279
- 's.retryDelay',
280
- 's.fromStepName',
281
- 's.createdAt',
282
- 's.updatedAt',
332
+ 'workflowStepId',
333
+ 'status',
334
+ 'result',
335
+ 'error',
336
+ 'retries',
337
+ 'retryDelay',
338
+ 'fromStepName',
339
+ 'currentAttempt',
340
+ 'createdAt',
341
+ 'updatedAt',
283
342
  ])
284
- .select((eb) => eb
285
- .selectFrom('workflowStepHistory')
286
- .select(eb.fn.countAll().as('cnt'))
287
- .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
288
- .as('attemptCount'))
289
- .where('s.workflowStepId', '=', stepId)
343
+ .where('workflowStepId', '=', stepId)
290
344
  .executeTakeFirstOrThrow();
291
345
  return {
292
346
  stepId: row.workflowStepId,
293
347
  status: row.status,
294
348
  result: parseJson(row.result),
295
349
  error: parseJson(row.error),
296
- attemptCount: Number(row.attemptCount),
350
+ attemptCount: Number(row.currentAttempt ?? 1),
297
351
  retries: row.retries != null ? Number(row.retries) : undefined,
298
352
  retryDelay: row.retryDelay ?? undefined,
299
353
  fromStepName: row.fromStepName ?? undefined,
@@ -339,18 +393,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
339
393
  }
340
394
  return { completedNodeIds, failedNodeIds, branchKeys };
341
395
  }
342
- async getNodesWithoutSteps(runId, nodeIds) {
343
- if (nodeIds.length === 0)
344
- return [];
345
- const result = await this.db
346
- .selectFrom('workflowStep')
347
- .select('stepName')
348
- .where('workflowRunId', '=', runId)
349
- .where('stepName', 'in', nodeIds)
350
- .execute();
351
- const existingStepNames = new Set(result.map((r) => r.stepName));
352
- return nodeIds.filter((id) => !existingStepNames.has(id));
353
- }
354
396
  async getStepInstances(runId) {
355
397
  const rows = await this.db
356
398
  .selectFrom('workflowStep')
@@ -386,17 +428,39 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
386
428
  .where('workflowStepId', '=', stepId)
387
429
  .execute();
388
430
  }
431
+ /**
432
+ * Merge one key into the run's JSON state, as a single expression evaluated
433
+ * by the database.
434
+ *
435
+ * The SQL is dialect-specific, so subclasses override this rather than the
436
+ * caller. The default is the SQLite form; MySQL and Postgres differ in how
437
+ * they cast a JSON literal.
438
+ *
439
+ * @param path - JSON path to the key, already quoted (`$."key"`)
440
+ * @param json - The value as JSON text
441
+ */
442
+ jsonSetState(path, json) {
443
+ return sql `json_set(coalesce(state, '{}'), ${path}, json(${json}))`;
444
+ }
445
+ /**
446
+ * A JSON path for an arbitrary key. State keys are user-supplied (a graph's
447
+ * `setState` name, an approval's hex-encoded reason), so the key is quoted
448
+ * rather than interpolated bare — an unquoted `$.a.b` would silently address
449
+ * a nested path instead of the key literally called `a.b`.
450
+ */
451
+ jsonPathFor(name) {
452
+ return `$.${JSON.stringify(name)}`;
453
+ }
389
454
  async updateRunStateImpl(runId, name, value) {
390
- const row = await this.db
391
- .selectFrom('workflowRuns')
392
- .select('state')
393
- .where('workflowRunId', '=', runId)
394
- .executeTakeFirst();
395
- const state = parseJson(row?.state) ?? {};
396
- state[name] = value;
455
+ // Merged by the database, not read-modify-written in JS: two graph nodes
456
+ // calling setState concurrently used to race, and the later write silently
457
+ // dropped whichever key the earlier one had added.
397
458
  await this.db
398
459
  .updateTable('workflowRuns')
399
- .set({ state: JSON.stringify(state), updatedAt: new Date() })
460
+ .set({
461
+ state: this.jsonSetState(this.jsonPathFor(name), JSON.stringify(value ?? null)),
462
+ updatedAt: new Date(),
463
+ })
400
464
  .where('workflowRunId', '=', runId)
401
465
  .execute();
402
466
  }
@@ -434,21 +498,5 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
434
498
  async getWorkflowVersion(name, graphHash) {
435
499
  return this.runService.getWorkflowVersion(name, graphHash);
436
500
  }
437
- async getAIGeneratedWorkflows(agentName) {
438
- let query = this.db
439
- .selectFrom('workflowVersions')
440
- .select(['workflowName', 'graphHash', 'graph'])
441
- .where('source', '=', 'dynamic-workflow')
442
- .where('status', '=', 'active');
443
- if (agentName) {
444
- query = query.where('workflowName', 'like', `ai:${agentName}:%`);
445
- }
446
- const rows = await query.execute();
447
- return rows.map((row) => ({
448
- workflowName: row.workflowName,
449
- graphHash: row.graphHash,
450
- graph: typeof row.graph === 'string' ? JSON.parse(row.graph) : row.graph,
451
- }));
452
- }
453
501
  async close() { }
454
502
  }
@@ -22,6 +22,11 @@ export const credentialSchema = {
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
24
  rawStatement(sql `CREATE UNIQUE INDEX credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`),
25
+ (db) => db.schema
26
+ .createTable('credentialKekSalts')
27
+ .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
28
+ .addColumn('salt', 'varchar(64)', (col) => col.notNull())
29
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
25
30
  (db) => db.schema
26
31
  .createTable('credentialsAudit')
27
32
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
@@ -29,6 +29,18 @@ export const scopeSchema = {
29
29
  .createTable('pikkuRoles')
30
30
  .addColumn('name', 'text', (col) => col.primaryKey())
31
31
  .addColumn('description', 'text')
32
+ // Declared in code with `defineSystemRole` rather than composed by an
33
+ // admin. What it buys is the refusals: a system role cannot be renamed,
34
+ // re-scoped or deleted from the console, and a console role cannot be
35
+ // created with its name — two rows answering to one name would make
36
+ // "does Susan hold `buyer`?" depend on which one the store returned.
37
+ .addColumn('system', 'boolean', (col) => col.defaultTo(false).notNull())
38
+ // A system role whose declaration has gone: still held by whoever holds
39
+ // it, no longer offered for new grants, awaiting `pikku roles prune`.
40
+ // The same additive contract as `pikkuScopes.declared`, and for the same
41
+ // reason — a mid-deploy revocation is not something a code edit should
42
+ // be able to cause.
43
+ .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
32
44
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
33
45
  (db) => db.schema
34
46
  .createTable('pikkuRoleScopes')
@@ -19,6 +19,11 @@ export const secretSchema = {
19
19
  .addColumn('keyVersion', 'integer', (col) => col.notNull())
20
20
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
21
21
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
22
+ (db) => db.schema
23
+ .createTable('secretKekSalts')
24
+ .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
25
+ .addColumn('salt', 'varchar(64)', (col) => col.notNull())
26
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
22
27
  (db) => db.schema
23
28
  .createTable('secretsAudit')
24
29
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
@@ -6,8 +6,10 @@ import type { PikkuSchema } from './pikku-schema.types.js';
6
6
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
7
7
  * evaluates once while the statement is built — every row would have taken the
8
8
  * same default, so it was never a generator; the services supply the id. And
9
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
10
- * {})` bolted on after the fact, which is the job a migration does.
9
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
10
+ * `workflowStepHistory.attempt` were each backfilled by an
11
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
12
+ * migration does — they are declared here and nowhere else.
11
13
  *
12
14
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
13
15
  * declaration now, and both services read it.
@@ -6,8 +6,10 @@ import { sql } from 'kysely';
6
6
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
7
7
  * evaluates once while the statement is built — every row would have taken the
8
8
  * same default, so it was never a generator; the services supply the id. And
9
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
10
- * {})` bolted on after the fact, which is the job a migration does.
9
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
10
+ * `workflowStepHistory.attempt` were each backfilled by an
11
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
12
+ * migration does — they are declared here and nowhere else.
11
13
  *
12
14
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
13
15
  * declaration now, and both services read it.
@@ -49,6 +51,7 @@ export const workflowSchema = {
49
51
  .addColumn('retries', 'integer')
50
52
  .addColumn('retryDelay', 'text')
51
53
  .addColumn('fromStepName', 'text')
54
+ .addColumn('currentAttempt', 'integer')
52
55
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
53
56
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
54
57
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -65,6 +68,7 @@ export const workflowSchema = {
65
68
  .addColumn('status', 'text', (col) => col.notNull())
66
69
  .addColumn('result', 'text')
67
70
  .addColumn('error', 'text')
71
+ .addColumn('attempt', 'integer')
68
72
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
69
73
  .addColumn('runningAt', 'timestamp')
70
74
  .addColumn('scheduledAt', 'timestamp')
@@ -82,5 +86,27 @@ export const workflowSchema = {
82
86
  'workflowName',
83
87
  'graphHash',
84
88
  ]),
89
+ // The indexes trail the tables so that a table is never indexed before it
90
+ // exists, whatever order the declarations are read in.
91
+ (db) => db.schema
92
+ .createIndex('idx_workflow_runs_status_created')
93
+ .on('workflowRuns')
94
+ .columns(['status', 'createdAt']),
95
+ (db) => db.schema
96
+ .createIndex('idx_workflow_runs_workflow_created')
97
+ .on('workflowRuns')
98
+ .columns(['workflow', 'createdAt']),
99
+ (db) => db.schema
100
+ .createIndex('idx_workflow_step_run_status')
101
+ .on('workflowStep')
102
+ .columns(['workflowRunId', 'status']),
103
+ (db) => db.schema
104
+ .createIndex('idx_workflow_step_history_step')
105
+ .on('workflowStepHistory')
106
+ .columns(['workflowStepId', 'createdAt']),
107
+ (db) => db.schema
108
+ .createIndex('idx_workflow_versions_source_status')
109
+ .on('workflowVersions')
110
+ .columns(['source', 'status']),
85
111
  ],
86
112
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
+ "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
4
5
  "author": "yasser.fadl@gmail.com",
5
6
  "license": "MIT",
6
7
  "module": "dist/src/index.js",
@@ -20,7 +21,7 @@
20
21
  "prepublishOnly": "yarn build"
21
22
  },
22
23
  "peerDependencies": {
23
- "@pikku/core": "^0.12.69"
24
+ "@pikku/core": "^0.12.74"
24
25
  },
25
26
  "dependencies": {
26
27
  "kysely": "^0.29.0"