@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.
- package/CHANGELOG.md +84 -0
- package/README.md +12 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/kysely-lease-clock.d.ts +14 -0
- package/dist/src/kysely-lease-clock.js +14 -0
- package/dist/src/kysely-lease-service.d.ts +37 -0
- package/dist/src/kysely-lease-service.js +116 -0
- package/dist/src/kysely-tables.d.ts +16 -0
- package/dist/src/kysely-workflow-service.d.ts +36 -20
- package/dist/src/kysely-workflow-service.js +195 -102
- package/dist/src/schema/agent.schema.js +43 -36
- package/dist/src/schema/analytics.schema.js +17 -17
- package/dist/src/schema/audit.schema.js +22 -22
- package/dist/src/schema/channel.schema.js +13 -10
- package/dist/src/schema/credential.schema.js +6 -6
- package/dist/src/schema/deployment.schema.js +12 -12
- package/dist/src/schema/flag.schema.js +15 -12
- package/dist/src/schema/incoming-webhook.schema.js +7 -7
- package/dist/src/schema/index.d.ts +1 -0
- package/dist/src/schema/index.js +33 -7
- package/dist/src/schema/lease.schema.d.ts +2 -0
- package/dist/src/schema/lease.schema.js +12 -0
- package/dist/src/schema/pikku-schema.types.d.ts +42 -2
- package/dist/src/schema/pikku-schema.types.js +23 -7
- package/dist/src/schema/scope.schema.js +34 -22
- package/dist/src/schema/secret.schema.js +5 -5
- package/dist/src/schema/session.schema.js +3 -3
- package/dist/src/schema/trigger-source.schema.js +8 -8
- package/dist/src/schema/virtual-user-schedule.schema.js +4 -4
- package/dist/src/schema/virtual-user.schema.js +14 -14
- package/dist/src/schema/webhook.schema.js +17 -17
- package/dist/src/schema/workflow.schema.js +50 -49
- package/knowledge/decisions/a-lease-is-taken-by-one-guarded-update.md +30 -0
- package/knowledge/decisions/index.md +3 -0
- package/knowledge/decisions/leases-are-judged-by-the-database-clock.md +42 -0
- package/knowledge/decisions/workflows-own-the-lease-table-too.md +22 -0
- package/package.json +2 -2
|
@@ -7,17 +7,17 @@ export const triggerSourceSchema = {
|
|
|
7
7
|
name: 'trigger-source',
|
|
8
8
|
ownedBy: ['triggerSourceStore'],
|
|
9
9
|
statements: [
|
|
10
|
-
(db) => db.schema
|
|
10
|
+
(db, _types, ctx) => db.schema
|
|
11
11
|
.createTable('pikkuTriggerSource')
|
|
12
|
-
.addColumn('name',
|
|
13
|
-
.addColumn('kind',
|
|
14
|
-
.addColumn('baseUrl',
|
|
15
|
-
.addColumn('labelPrefix',
|
|
12
|
+
.addColumn('name', ctx.key, (col) => col.primaryKey())
|
|
13
|
+
.addColumn('kind', ctx.text, (col) => col.notNull())
|
|
14
|
+
.addColumn('baseUrl', ctx.text)
|
|
15
|
+
.addColumn('labelPrefix', ctx.text)
|
|
16
16
|
.addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
|
|
17
17
|
.addColumn('enabled', 'boolean', (col) => col.defaultTo(false).notNull())
|
|
18
|
-
.addColumn('status',
|
|
19
|
-
.addColumn('state',
|
|
20
|
-
.addColumn('detail',
|
|
18
|
+
.addColumn('status', ctx.text)
|
|
19
|
+
.addColumn('state', ctx.text)
|
|
20
|
+
.addColumn('detail', ctx.text)
|
|
21
21
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
22
22
|
],
|
|
23
23
|
};
|
|
@@ -15,7 +15,7 @@ export const virtualUserScheduleSchema = {
|
|
|
15
15
|
name: 'virtual-user-schedule',
|
|
16
16
|
ownedBy: ['virtualUserScheduleStore'],
|
|
17
17
|
statements: [
|
|
18
|
-
(db) => db.schema
|
|
18
|
+
(db, _types, ctx) => db.schema
|
|
19
19
|
.createTable('virtualUserSchedule')
|
|
20
20
|
.addColumn('persona', 'varchar(255)', (col) => col.primaryKey())
|
|
21
21
|
// 0 or 1 rather than a boolean, matching the run tables: a bare sqlite
|
|
@@ -23,8 +23,8 @@ export const virtualUserScheduleSchema = {
|
|
|
23
23
|
// everywhere. Off by default, so creating the table spends nothing.
|
|
24
24
|
.addColumn('enabled', 'integer', (col) => col.defaultTo(0).notNull())
|
|
25
25
|
.addColumn('disposition', 'varchar(50)', (col) => col.notNull())
|
|
26
|
-
.addColumn('goals',
|
|
27
|
-
.addColumn('budget',
|
|
26
|
+
.addColumn('goals', ctx.text, (col) => col.defaultTo(ctx.defaultText('[]')).notNull())
|
|
27
|
+
.addColumn('budget', ctx.text)
|
|
28
28
|
// The gap to the next run is drawn between these rather than fixed, so
|
|
29
29
|
// the persona does not keep an appointment.
|
|
30
30
|
.addColumn('minIntervalMs', 'bigint', (col) => col.notNull())
|
|
@@ -35,7 +35,7 @@ export const virtualUserScheduleSchema = {
|
|
|
35
35
|
.addColumn('lastRunId', 'varchar(36)')
|
|
36
36
|
.addColumn('lastRunAt', 'timestamp'),
|
|
37
37
|
// The only read that matters at speed: which rows are due, on every tick.
|
|
38
|
-
(db) => db.schema
|
|
38
|
+
(db, _types, ctx) => db.schema
|
|
39
39
|
.createIndex('idx_virtual_user_schedule_due')
|
|
40
40
|
.on('virtualUserSchedule')
|
|
41
41
|
.columns(['enabled', 'nextRunAt']),
|
|
@@ -21,7 +21,7 @@ export const virtualUserSchema = {
|
|
|
21
21
|
name: 'virtual-user',
|
|
22
22
|
ownedBy: ['virtualUserRunStore'],
|
|
23
23
|
statements: [
|
|
24
|
-
(db) => db.schema
|
|
24
|
+
(db, _types, ctx) => db.schema
|
|
25
25
|
.createTable('virtualUserRun')
|
|
26
26
|
.addColumn('runId', 'varchar(36)', (col) => col.primaryKey())
|
|
27
27
|
.addColumn('persona', 'varchar(255)', (col) => col.notNull())
|
|
@@ -30,29 +30,29 @@ export const virtualUserSchema = {
|
|
|
30
30
|
// running | completed | failed. Not derived from `finishedAt` being
|
|
31
31
|
// null: a crashed run has no finish time either, and "crashed" and
|
|
32
32
|
// "still going" are not the same answer.
|
|
33
|
-
.addColumn('status', 'varchar(50)', (col) => col.defaultTo('running').notNull())
|
|
34
|
-
.addColumn('goals',
|
|
35
|
-
.addColumn('memory',
|
|
36
|
-
.addColumn('findings',
|
|
33
|
+
.addColumn('status', 'varchar(50)', (col) => col.defaultTo(ctx.defaultText('running')).notNull())
|
|
34
|
+
.addColumn('goals', ctx.text, (col) => col.defaultTo(ctx.defaultText('[]')).notNull())
|
|
35
|
+
.addColumn('memory', ctx.text, (col) => col.defaultTo(ctx.defaultText('{}')).notNull())
|
|
36
|
+
.addColumn('findings', ctx.text, (col) => col.defaultTo(ctx.defaultText('[]')).notNull())
|
|
37
37
|
// What the user set out to do and how far each one got. On the run row
|
|
38
38
|
// rather than beside the steps: there are a handful of them, and every
|
|
39
39
|
// read of the run wants them.
|
|
40
|
-
.addColumn('intents',
|
|
40
|
+
.addColumn('intents', ctx.text, (col) => col.defaultTo(ctx.defaultText('[]')).notNull())
|
|
41
41
|
// Counts the engine kept: steps, calls, mutations, tokens, elapsed.
|
|
42
|
-
.addColumn('tally',
|
|
42
|
+
.addColumn('tally', ctx.text)
|
|
43
43
|
.addColumn('stoppedBy', 'varchar(255)')
|
|
44
44
|
// Why the run itself failed, as opposed to what it found.
|
|
45
|
-
.addColumn('error',
|
|
45
|
+
.addColumn('error', ctx.text)
|
|
46
46
|
.addColumn('startedBy', 'varchar(255)')
|
|
47
47
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
48
48
|
.addColumn('finishedAt', 'timestamp'),
|
|
49
49
|
// The two reads: the run list newest-first, and one persona's history when
|
|
50
50
|
// a finding needs comparing against what that persona found before.
|
|
51
|
-
(db) => db.schema
|
|
51
|
+
(db, _types, ctx) => db.schema
|
|
52
52
|
.createIndex('idx_virtual_user_run_created')
|
|
53
53
|
.on('virtualUserRun')
|
|
54
54
|
.column('createdAt'),
|
|
55
|
-
(db) => db.schema
|
|
55
|
+
(db, _types, ctx) => db.schema
|
|
56
56
|
.createIndex('idx_virtual_user_run_persona')
|
|
57
57
|
.on('virtualUserRun')
|
|
58
58
|
.columns(['persona', 'createdAt']),
|
|
@@ -62,7 +62,7 @@ export const virtualUserSchema = {
|
|
|
62
62
|
// created by the store at boot on whatever engine the project uses, and a
|
|
63
63
|
// cascade that half the engines enforce differently is worse than the read
|
|
64
64
|
// rule that a step with no run is orphaned.
|
|
65
|
-
(db) => db.schema
|
|
65
|
+
(db, _types, ctx) => db.schema
|
|
66
66
|
.createTable('virtualUserRunStep')
|
|
67
67
|
.addColumn('runId', 'varchar(36)', (col) => col.notNull())
|
|
68
68
|
// The engine's own step number, which is what the intent records point
|
|
@@ -72,7 +72,7 @@ export const virtualUserSchema = {
|
|
|
72
72
|
.addColumn('intentId', 'varchar(255)')
|
|
73
73
|
// The action as the engine scheduled it, including the `invalid` shape
|
|
74
74
|
// for a turn the model got wrong — that turn is the interesting one.
|
|
75
|
-
.addColumn('action',
|
|
75
|
+
.addColumn('action', ctx.text, (col) => col.notNull())
|
|
76
76
|
.addColumn('status', 'integer')
|
|
77
77
|
// 0 or 1 rather than a boolean, because a bare sqlite driver cannot
|
|
78
78
|
// bind one at all and `SerializePlugin` is not installed everywhere.
|
|
@@ -81,8 +81,8 @@ export const virtualUserSchema = {
|
|
|
81
81
|
// raw: a truncated API response usually starts with a brace, which
|
|
82
82
|
// `SerializePlugin` would read back as an object rather than the string
|
|
83
83
|
// the engine actually saw.
|
|
84
|
-
.addColumn('response',
|
|
85
|
-
.addColumn('findingKinds',
|
|
84
|
+
.addColumn('response', ctx.text)
|
|
85
|
+
.addColumn('findingKinds', ctx.text)
|
|
86
86
|
.addColumn('tokensIn', 'integer', (col) => col.defaultTo(0).notNull())
|
|
87
87
|
.addColumn('tokensOut', 'integer', (col) => col.defaultTo(0).notNull())
|
|
88
88
|
.addPrimaryKeyConstraint('pk_virtual_user_run_step', [
|
|
@@ -4,34 +4,34 @@ export const webhookSchema = {
|
|
|
4
4
|
name: 'webhook',
|
|
5
5
|
ownedBy: ['webhookService'],
|
|
6
6
|
statements: [
|
|
7
|
-
(db) => db.schema
|
|
7
|
+
(db, _types, ctx) => db.schema
|
|
8
8
|
.createTable('webhookDelivery')
|
|
9
|
-
.addColumn('deliveryId',
|
|
10
|
-
.addColumn('organizationId',
|
|
11
|
-
.addColumn('url',
|
|
12
|
-
.addColumn('event',
|
|
13
|
-
.addColumn('status',
|
|
9
|
+
.addColumn('deliveryId', ctx.key, (col) => col.primaryKey())
|
|
10
|
+
.addColumn('organizationId', ctx.key)
|
|
11
|
+
.addColumn('url', ctx.text, (col) => col.notNull())
|
|
12
|
+
.addColumn('event', ctx.text)
|
|
13
|
+
.addColumn('status', ctx.text, (col) => col.defaultTo(ctx.defaultText('pending')).notNull())
|
|
14
14
|
.addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
|
|
15
15
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
16
16
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
17
17
|
.addColumn('deliveredAt', 'timestamp'),
|
|
18
|
-
(db) => db.schema
|
|
18
|
+
(db, _types, ctx) => db.schema
|
|
19
19
|
.createTable('webhookDeliveryAttempt')
|
|
20
|
-
.addColumn('attemptId',
|
|
21
|
-
.addColumn('deliveryId',
|
|
22
|
-
.notNull()
|
|
23
|
-
.references('webhookDelivery.deliveryId')
|
|
24
|
-
.onDelete('cascade'))
|
|
20
|
+
.addColumn('attemptId', ctx.key, (col) => col.primaryKey())
|
|
21
|
+
.addColumn('deliveryId', ctx.key, (col) => ctx.references(col.notNull(), 'webhookDelivery.deliveryId'))
|
|
25
22
|
.addColumn('attemptNumber', 'integer', (col) => col.notNull())
|
|
26
23
|
.addColumn('statusCode', 'integer')
|
|
27
|
-
.addColumn('responseBody',
|
|
28
|
-
.addColumn('error',
|
|
29
|
-
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
30
|
-
|
|
24
|
+
.addColumn('responseBody', ctx.text)
|
|
25
|
+
.addColumn('error', ctx.text)
|
|
26
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
27
|
+
.$call(ctx.foreignKeys('webhookDeliveryAttempt', {
|
|
28
|
+
deliveryId: 'webhookDelivery.deliveryId',
|
|
29
|
+
})),
|
|
30
|
+
(db, _types, ctx) => db.schema
|
|
31
31
|
.createIndex('idx_webhook_delivery_org')
|
|
32
32
|
.on('webhookDelivery')
|
|
33
33
|
.column('organizationId'),
|
|
34
|
-
(db) => db.schema
|
|
34
|
+
(db, _types, ctx) => db.schema
|
|
35
35
|
.createIndex('idx_webhook_delivery_attempt_delivery')
|
|
36
36
|
.on('webhookDeliveryAttempt')
|
|
37
37
|
.column('deliveryId'),
|
|
@@ -18,70 +18,71 @@ export const workflowSchema = {
|
|
|
18
18
|
name: 'workflow',
|
|
19
19
|
ownedBy: ['workflowService', 'workflowRunService'],
|
|
20
20
|
statements: [
|
|
21
|
-
(db) => db.schema
|
|
21
|
+
(db, _types, ctx) => db.schema
|
|
22
22
|
.createTable('workflowRuns')
|
|
23
|
-
.addColumn('workflowRunId',
|
|
24
|
-
.addColumn('workflow',
|
|
25
|
-
.addColumn('status',
|
|
26
|
-
.addColumn('input',
|
|
27
|
-
.addColumn('output',
|
|
28
|
-
.addColumn('error',
|
|
29
|
-
.addColumn('state',
|
|
23
|
+
.addColumn('workflowRunId', ctx.key, (col) => col.primaryKey())
|
|
24
|
+
.addColumn('workflow', ctx.key, (col) => col.notNull())
|
|
25
|
+
.addColumn('status', ctx.key, (col) => col.notNull())
|
|
26
|
+
.addColumn('input', ctx.text, (col) => col.notNull())
|
|
27
|
+
.addColumn('output', ctx.text)
|
|
28
|
+
.addColumn('error', ctx.text)
|
|
29
|
+
.addColumn('state', ctx.text, (col) => col.defaultTo(ctx.defaultText('{}')))
|
|
30
30
|
.addColumn('inline', 'boolean', (col) => col.defaultTo(false))
|
|
31
|
-
.addColumn('graphHash',
|
|
31
|
+
.addColumn('graphHash', ctx.key)
|
|
32
32
|
.addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
|
|
33
|
-
.addColumn('plannedSteps',
|
|
34
|
-
.addColumn('wire',
|
|
33
|
+
.addColumn('plannedSteps', ctx.text)
|
|
34
|
+
.addColumn('wire', ctx.text)
|
|
35
35
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
36
36
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
37
|
-
(db) => db.schema
|
|
37
|
+
(db, _types, ctx) => db.schema
|
|
38
38
|
.createTable('workflowStep')
|
|
39
|
-
.addColumn('workflowStepId',
|
|
40
|
-
.addColumn('workflowRunId',
|
|
41
|
-
.notNull()
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.addColumn('
|
|
45
|
-
.addColumn('
|
|
46
|
-
.addColumn('
|
|
47
|
-
.addColumn('
|
|
48
|
-
.addColumn('
|
|
49
|
-
.addColumn('error', 'text')
|
|
50
|
-
.addColumn('childRunId', 'text')
|
|
51
|
-
.addColumn('branchTaken', 'text')
|
|
39
|
+
.addColumn('workflowStepId', ctx.key, (col) => col.primaryKey())
|
|
40
|
+
.addColumn('workflowRunId', ctx.key, (col) => ctx.references(col.notNull(), 'workflowRuns.workflowRunId'))
|
|
41
|
+
.addColumn('stepName', ctx.key, (col) => col.notNull())
|
|
42
|
+
.addColumn('rpcName', ctx.text)
|
|
43
|
+
.addColumn('data', ctx.text)
|
|
44
|
+
.addColumn('status', ctx.key, (col) => col.notNull().defaultTo(ctx.defaultText('pending')))
|
|
45
|
+
.addColumn('result', ctx.text)
|
|
46
|
+
.addColumn('error', ctx.text)
|
|
47
|
+
.addColumn('childRunId', ctx.text)
|
|
48
|
+
.addColumn('branchTaken', ctx.text)
|
|
52
49
|
.addColumn('retries', 'integer')
|
|
53
|
-
.addColumn('retryDelay',
|
|
54
|
-
.addColumn('fromStepName',
|
|
50
|
+
.addColumn('retryDelay', ctx.text)
|
|
51
|
+
.addColumn('fromStepName', ctx.text)
|
|
55
52
|
.addColumn('currentAttempt', 'integer')
|
|
53
|
+
.addColumn('leaseExpiresAt', 'bigint')
|
|
56
54
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
57
55
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
58
56
|
.addUniqueConstraint('workflow_step_run_name_unique', [
|
|
59
57
|
'workflowRunId',
|
|
60
58
|
'stepName',
|
|
61
|
-
])
|
|
62
|
-
|
|
59
|
+
])
|
|
60
|
+
.$call(ctx.foreignKeys('workflowStep', {
|
|
61
|
+
workflowRunId: 'workflowRuns.workflowRunId',
|
|
62
|
+
})),
|
|
63
|
+
(db, _types, ctx) => db.schema
|
|
63
64
|
.createTable('workflowStepHistory')
|
|
64
|
-
.addColumn('historyId',
|
|
65
|
-
.addColumn('workflowStepId',
|
|
66
|
-
.notNull()
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.addColumn('status', 'text', (col) => col.notNull())
|
|
70
|
-
.addColumn('result', 'text')
|
|
71
|
-
.addColumn('error', 'text')
|
|
65
|
+
.addColumn('historyId', ctx.key, (col) => col.primaryKey())
|
|
66
|
+
.addColumn('workflowStepId', ctx.key, (col) => ctx.references(col.notNull(), 'workflowStep.workflowStepId'))
|
|
67
|
+
.addColumn('status', ctx.key, (col) => col.notNull())
|
|
68
|
+
.addColumn('result', ctx.text)
|
|
69
|
+
.addColumn('error', ctx.text)
|
|
72
70
|
.addColumn('attempt', 'integer')
|
|
73
71
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
74
72
|
.addColumn('runningAt', 'timestamp')
|
|
75
73
|
.addColumn('scheduledAt', 'timestamp')
|
|
76
74
|
.addColumn('succeededAt', 'timestamp')
|
|
77
|
-
.addColumn('failedAt', 'timestamp')
|
|
78
|
-
|
|
75
|
+
.addColumn('failedAt', 'timestamp')
|
|
76
|
+
.$call(ctx.foreignKeys('workflowStepHistory', {
|
|
77
|
+
workflowStepId: 'workflowStep.workflowStepId',
|
|
78
|
+
})),
|
|
79
|
+
(db, _types, ctx) => db.schema
|
|
79
80
|
.createTable('workflowVersions')
|
|
80
|
-
.addColumn('workflowName',
|
|
81
|
-
.addColumn('graphHash',
|
|
82
|
-
.addColumn('graph',
|
|
83
|
-
.addColumn('source',
|
|
84
|
-
.addColumn('status',
|
|
81
|
+
.addColumn('workflowName', ctx.key, (col) => col.notNull())
|
|
82
|
+
.addColumn('graphHash', ctx.key, (col) => col.notNull())
|
|
83
|
+
.addColumn('graph', ctx.text, (col) => col.notNull())
|
|
84
|
+
.addColumn('source', ctx.key, (col) => col.notNull())
|
|
85
|
+
.addColumn('status', ctx.key, (col) => col.notNull().defaultTo(ctx.defaultText('active')))
|
|
85
86
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
86
87
|
.addPrimaryKeyConstraint('workflow_versions_pk', [
|
|
87
88
|
'workflowName',
|
|
@@ -89,23 +90,23 @@ export const workflowSchema = {
|
|
|
89
90
|
]),
|
|
90
91
|
// The indexes trail the tables so that a table is never indexed before it
|
|
91
92
|
// exists, whatever order the declarations are read in.
|
|
92
|
-
(db) => db.schema
|
|
93
|
+
(db, _types, ctx) => db.schema
|
|
93
94
|
.createIndex('idx_workflow_runs_status_created')
|
|
94
95
|
.on('workflowRuns')
|
|
95
96
|
.columns(['status', 'createdAt']),
|
|
96
|
-
(db) => db.schema
|
|
97
|
+
(db, _types, ctx) => db.schema
|
|
97
98
|
.createIndex('idx_workflow_runs_workflow_created')
|
|
98
99
|
.on('workflowRuns')
|
|
99
100
|
.columns(['workflow', 'createdAt']),
|
|
100
|
-
(db) => db.schema
|
|
101
|
+
(db, _types, ctx) => db.schema
|
|
101
102
|
.createIndex('idx_workflow_step_run_status')
|
|
102
103
|
.on('workflowStep')
|
|
103
104
|
.columns(['workflowRunId', 'status']),
|
|
104
|
-
(db) => db.schema
|
|
105
|
+
(db, _types, ctx) => db.schema
|
|
105
106
|
.createIndex('idx_workflow_step_history_step')
|
|
106
107
|
.on('workflowStepHistory')
|
|
107
108
|
.columns(['workflowStepId', 'createdAt']),
|
|
108
|
-
(db) => db.schema
|
|
109
|
+
(db, _types, ctx) => db.schema
|
|
109
110
|
.createIndex('idx_workflow_versions_source_status')
|
|
110
111
|
.on('workflowVersions')
|
|
111
112
|
.columns(['source', 'status']),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A lease is taken by one guarded UPDATE
|
|
4
|
+
description: Acquire inserts the key's row already lapsed, takes it with a guarded UPDATE, and reads the row back, because that is the one shape that behaves the same on SQLite, Postgres and MySQL
|
|
5
|
+
tags: [leases, kysely, mysql]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A lease is taken by one guarded UPDATE
|
|
9
|
+
|
|
10
|
+
`acquire` is three statements, in an order that looks roundabout:
|
|
11
|
+
|
|
12
|
+
1. insert the key's row with `token = 0, expires_at = 0` — already lapsed —
|
|
13
|
+
ignoring a conflict;
|
|
14
|
+
2. `UPDATE` it to this holder where it is this holder's or has lapsed, raising
|
|
15
|
+
`token` unless this holder is re-acquiring a live lease;
|
|
16
|
+
3. read the row back and answer whether this holder now has it.
|
|
17
|
+
|
|
18
|
+
The natural shape, `INSERT … ON CONFLICT DO UPDATE … WHERE`, does not exist on
|
|
19
|
+
MySQL, whose `ON DUPLICATE KEY UPDATE` has no guard. Inserting the row lapsed
|
|
20
|
+
means there is a single path to holding a key — the guarded update — whether the
|
|
21
|
+
key is new or not.
|
|
22
|
+
|
|
23
|
+
Two MySQL behaviours shape the rest. `SET` is evaluated left to right against
|
|
24
|
+
values already assigned in the same statement, so `token` is assigned before
|
|
25
|
+
`holder`: the re-acquire test has to see the previous holder. And MySQL reports
|
|
26
|
+
rows _changed_, not rows _matched_, so a holder re-acquiring with an unchanged
|
|
27
|
+
row would look like a loss; the row is read back instead of trusting the count.
|
|
28
|
+
|
|
29
|
+
**What this rules out:** a dialect-specific upsert per database, and deciding
|
|
30
|
+
the outcome from `numUpdatedRows`.
|
|
@@ -8,8 +8,11 @@ description: How encrypted columns are wrapped, and which key wraps them
|
|
|
8
8
|
|
|
9
9
|
<!-- pikku:knowledge-index -->
|
|
10
10
|
|
|
11
|
+
- [A lease is taken by one guarded UPDATE](a-lease-is-taken-by-one-guarded-update.md) — Acquire inserts the key's row already lapsed, takes it with a guarded UPDATE, and reads the row back, because that is the one shape that behaves the same on SQLite, Postgres and MySQL
|
|
11
12
|
- [Classification-driven encryption happens in a Kysely plugin](classification-driven-encryption-happens-in-a-plugin.md) — The existing column classification manifest drives a transparent query plugin, rather than encrypting the whole database file or asking call sites to encrypt
|
|
12
13
|
- [KEKs are scoped by purpose as well as version](keks-are-scoped-by-purpose-as-well-as-version.md) — Rows carry a keyId, and the plugin resolves KEKs through a seam rather than holding one; v1 ships a single key, but scoping never becomes a migration
|
|
14
|
+
- [Leases are judged by the database's clock](leases-are-judged-by-the-database-clock.md) — A lease and a step lease are written and compared in SQL against the server's own clock, stored as epoch milliseconds, so no worker's clock decides who holds anything
|
|
13
15
|
- [The plugin is given a resolver, never a key](the-plugin-is-given-a-resolver-never-a-key.md) — Assembly order for classified columns — a plain Kysely handle, then a plugin that asks a `KEKResolver` per operation rather than being handed a key it holds for the life of the process
|
|
16
|
+
- [Workflows own the lease table too](workflows-own-the-lease-table-too.md) — `leaseSchema` is owned by `workflowService` as well as `leaseService`, because the engine takes the run lease without any function ever naming leaseService
|
|
14
17
|
|
|
15
18
|
<!-- /pikku:knowledge-index -->
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Leases are judged by the database's clock
|
|
4
|
+
description: A lease and a step lease are written and compared in SQL against the server's own clock, stored as epoch milliseconds, so no worker's clock decides who holds anything
|
|
5
|
+
tags: [leases, workflows, kysely, clocks]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Leases are judged by the database's clock
|
|
9
|
+
|
|
10
|
+
A lease is only as good as the clock that decides it has lapsed. When each
|
|
11
|
+
worker computed `new Date(Date.now() + ttl)` and compared against its own
|
|
12
|
+
`Date.now()`, a worker whose clock ran an hour fast saw every live lease as
|
|
13
|
+
expired and took it — a lease and a step claim both — and wrote leases that
|
|
14
|
+
outlived their ttl by the same hour for everyone else.
|
|
15
|
+
|
|
16
|
+
So both leases are computed in SQL: `expires_at` is set to _now + ttl_ and
|
|
17
|
+
compared against _now_, where _now_ is the database's —
|
|
18
|
+
`clock_timestamp()` on Postgres, `utc_timestamp(6)` on MySQL. There is one clock,
|
|
19
|
+
and every worker reads it. The columns are `bigint` epoch milliseconds rather
|
|
20
|
+
than `timestamp`, because the arithmetic then means the same thing in every
|
|
21
|
+
dialect and no driver gets to reinterpret a time zone.
|
|
22
|
+
|
|
23
|
+
On MySQL the epoch is counted from `utc_timestamp`, never
|
|
24
|
+
`unix_timestamp(now())`. `now()` is local to the session's time zone, and when
|
|
25
|
+
that zone falls back an hour, two real instants share one local time: the
|
|
26
|
+
epoch stood still for an hour, then jumped, so a dead holder's lease lived an
|
|
27
|
+
hour longer and every lease written in the repeated hour lapsed at once.
|
|
28
|
+
|
|
29
|
+
`PgKyselyLeaseService`, `MySQLKyselyLeaseService` and the dialect workflow
|
|
30
|
+
services override `nowMs()`; `KyselyLeaseService` and `KyselyWorkflowService`
|
|
31
|
+
keep the process clock, which is only correct when every process shares a host,
|
|
32
|
+
as they do on SQLite.
|
|
33
|
+
|
|
34
|
+
**What this rules out:** comparing a lease in JavaScript after reading it — the
|
|
35
|
+
claim used to pre-check `isStepLeaseLive` in the app, and that check is exactly
|
|
36
|
+
where a skewed worker decided wrongly.
|
|
37
|
+
|
|
38
|
+
**Testing it:** PGlite runs in-process and takes its clock from the JS
|
|
39
|
+
`Date.now`, so skewing `Date.now` skews the database too and a skew test on
|
|
40
|
+
PGlite proves nothing. The skew assertions live in
|
|
41
|
+
`verifiers/workflows/src/tests/lease-db.assert.ts`, against real Postgres and
|
|
42
|
+
MySQL servers.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Workflows own the lease table too
|
|
4
|
+
description: `leaseSchema` is owned by `workflowService` as well as `leaseService`, because the engine takes the run lease without any function ever naming leaseService
|
|
5
|
+
tags: [leases, workflows, schema]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Workflows own the lease table too
|
|
9
|
+
|
|
10
|
+
`pikku db generate` writes a schema for a project only when a service in
|
|
11
|
+
`ownedBy` is one the inspector found the project's code reaching. The workflow
|
|
12
|
+
engine takes `workflow-run:<id>` from `leaseService` on every orchestration pass,
|
|
13
|
+
but no function destructures `leaseService` to make that happen, so a workflow
|
|
14
|
+
project was never given `pikku_lease` — and registering a lease service then
|
|
15
|
+
failed at boot against a table nothing had migrated.
|
|
16
|
+
|
|
17
|
+
`leaseSchema` is therefore owned by `workflowService` as well. A workflow project
|
|
18
|
+
gets the table whether or not it registers a lease service yet.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** making `leaseService` a required service of every
|
|
21
|
+
workflow project. That would force the in-memory and single-process apps, which
|
|
22
|
+
have nothing to serialise, to wire a lease they cannot use.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.34",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "bun run build"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@pikku/core": "^0.12.
|
|
29
|
+
"@pikku/core": "^0.12.136"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"kysely": "^0.29.5"
|