@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
@@ -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', 'text', (col) => col.primaryKey())
13
- .addColumn('kind', 'text', (col) => col.notNull())
14
- .addColumn('baseUrl', 'text')
15
- .addColumn('labelPrefix', 'text')
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', 'text')
19
- .addColumn('state', 'text')
20
- .addColumn('detail', 'text')
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', 'text', (col) => col.defaultTo('[]').notNull())
27
- .addColumn('budget', 'text')
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', 'text', (col) => col.defaultTo('[]').notNull())
35
- .addColumn('memory', 'text', (col) => col.defaultTo('{}').notNull())
36
- .addColumn('findings', 'text', (col) => col.defaultTo('[]').notNull())
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', 'text', (col) => col.defaultTo('[]').notNull())
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', 'text')
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', 'text')
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', 'text', (col) => col.notNull())
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', 'text')
85
- .addColumn('findingKinds', 'text')
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', 'text', (col) => col.primaryKey())
10
- .addColumn('organizationId', 'text')
11
- .addColumn('url', 'text', (col) => col.notNull())
12
- .addColumn('event', 'text')
13
- .addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
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', 'text', (col) => col.primaryKey())
21
- .addColumn('deliveryId', 'text', (col) => col
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', 'text')
28
- .addColumn('error', 'text')
29
- .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
30
- (db) => db.schema
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', 'text', (col) => col.primaryKey())
24
- .addColumn('workflow', 'text', (col) => col.notNull())
25
- .addColumn('status', 'text', (col) => col.notNull())
26
- .addColumn('input', 'text', (col) => col.notNull())
27
- .addColumn('output', 'text')
28
- .addColumn('error', 'text')
29
- .addColumn('state', 'text', (col) => col.defaultTo('{}'))
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', 'text')
31
+ .addColumn('graphHash', ctx.key)
32
32
  .addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
33
- .addColumn('plannedSteps', 'text')
34
- .addColumn('wire', 'text')
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', 'text', (col) => col.primaryKey())
40
- .addColumn('workflowRunId', 'text', (col) => col
41
- .notNull()
42
- .references('workflowRuns.workflowRunId')
43
- .onDelete('cascade'))
44
- .addColumn('stepName', 'text', (col) => col.notNull())
45
- .addColumn('rpcName', 'text')
46
- .addColumn('data', 'text')
47
- .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
48
- .addColumn('result', 'text')
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', 'text')
54
- .addColumn('fromStepName', 'text')
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
- (db) => db.schema
59
+ ])
60
+ .$call(ctx.foreignKeys('workflowStep', {
61
+ workflowRunId: 'workflowRuns.workflowRunId',
62
+ })),
63
+ (db, _types, ctx) => db.schema
63
64
  .createTable('workflowStepHistory')
64
- .addColumn('historyId', 'text', (col) => col.primaryKey())
65
- .addColumn('workflowStepId', 'text', (col) => col
66
- .notNull()
67
- .references('workflowStep.workflowStepId')
68
- .onDelete('cascade'))
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
- (db) => db.schema
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', 'text', (col) => col.notNull())
81
- .addColumn('graphHash', 'text', (col) => col.notNull())
82
- .addColumn('graph', 'text', (col) => col.notNull())
83
- .addColumn('source', 'text', (col) => col.notNull())
84
- .addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
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.31",
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.130"
29
+ "@pikku/core": "^0.12.136"
30
30
  },
31
31
  "dependencies": {
32
32
  "kysely": "^0.29.5"