@kubun/store-workflow 0.13.0
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/LICENSE.md +57 -0
- package/lib/api.d.ts +87 -0
- package/lib/api.js +253 -0
- package/lib/definition.d.ts +4 -0
- package/lib/definition.js +7 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +7 -0
- package/lib/migrations.d.ts +4 -0
- package/lib/migrations.js +66 -0
- package/lib/tables.d.ts +36 -0
- package/lib/tables.js +2 -0
- package/package.json +40 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# The Prosperity Public License 3.0.0
|
|
2
|
+
|
|
3
|
+
Contributor: Paul Le Cam
|
|
4
|
+
|
|
5
|
+
Source Code: https://github.com/PaulLeCam/kubun
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
|
|
10
|
+
|
|
11
|
+
## Agreement
|
|
12
|
+
|
|
13
|
+
In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow.
|
|
14
|
+
|
|
15
|
+
## Notices
|
|
16
|
+
|
|
17
|
+
Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
|
|
18
|
+
|
|
19
|
+
## Commercial Trial
|
|
20
|
+
|
|
21
|
+
Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
|
|
22
|
+
|
|
23
|
+
## Contributions Back
|
|
24
|
+
|
|
25
|
+
Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
|
|
26
|
+
|
|
27
|
+
## Personal Uses
|
|
28
|
+
|
|
29
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
|
|
30
|
+
|
|
31
|
+
## Noncommercial Organizations
|
|
32
|
+
|
|
33
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
|
|
34
|
+
|
|
35
|
+
## Defense
|
|
36
|
+
|
|
37
|
+
Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
|
|
38
|
+
|
|
39
|
+
## Copyright
|
|
40
|
+
|
|
41
|
+
The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
|
|
42
|
+
|
|
43
|
+
## Patent
|
|
44
|
+
|
|
45
|
+
The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
|
|
46
|
+
|
|
47
|
+
## Reliability
|
|
48
|
+
|
|
49
|
+
The contributor can't revoke this license.
|
|
50
|
+
|
|
51
|
+
## Excuse
|
|
52
|
+
|
|
53
|
+
You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
|
|
54
|
+
|
|
55
|
+
## No Liability
|
|
56
|
+
|
|
57
|
+
***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
|
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { WorkflowInstance, WorkflowSchedule, WorkflowTables } from './tables.js';
|
|
4
|
+
export type WorkflowStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
5
|
+
export type WorkflowNextAction = {
|
|
6
|
+
name: string;
|
|
7
|
+
params?: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type WorkflowInstanceRow = Omit<WorkflowInstance, 'state' | 'next_action'> & {
|
|
10
|
+
state: unknown;
|
|
11
|
+
next_action: WorkflowNextAction | null;
|
|
12
|
+
};
|
|
13
|
+
export type InsertInstanceInput = {
|
|
14
|
+
instanceID: string;
|
|
15
|
+
name: string;
|
|
16
|
+
queue?: string;
|
|
17
|
+
idempotencyKey?: string | null;
|
|
18
|
+
singletonKey?: string | null;
|
|
19
|
+
priority?: number;
|
|
20
|
+
state?: unknown;
|
|
21
|
+
nextAction?: WorkflowNextAction | null;
|
|
22
|
+
runAfter?: number;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt?: number;
|
|
25
|
+
};
|
|
26
|
+
export type InsertReason = 'idempotent' | 'singleton';
|
|
27
|
+
export type InsertInstanceResult = {
|
|
28
|
+
row: WorkflowInstanceRow;
|
|
29
|
+
created: boolean;
|
|
30
|
+
reason?: InsertReason;
|
|
31
|
+
};
|
|
32
|
+
export type CheckpointInput = {
|
|
33
|
+
state: unknown;
|
|
34
|
+
nextAction: WorkflowNextAction | null;
|
|
35
|
+
status?: WorkflowStatus;
|
|
36
|
+
};
|
|
37
|
+
export type FailInput = {
|
|
38
|
+
state: unknown;
|
|
39
|
+
nextAction: WorkflowNextAction | null;
|
|
40
|
+
error: string;
|
|
41
|
+
attempts: number;
|
|
42
|
+
runAfter: number;
|
|
43
|
+
status: WorkflowStatus;
|
|
44
|
+
};
|
|
45
|
+
export type FinishInput = {
|
|
46
|
+
status: 'completed' | 'cancelled';
|
|
47
|
+
state: unknown;
|
|
48
|
+
};
|
|
49
|
+
export type ListFilter = {
|
|
50
|
+
name?: string;
|
|
51
|
+
queue?: string;
|
|
52
|
+
status?: WorkflowStatus;
|
|
53
|
+
};
|
|
54
|
+
export type WorkflowScheduleRow = Omit<WorkflowSchedule, 'params' | 'enabled'> & {
|
|
55
|
+
params: unknown;
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
};
|
|
58
|
+
export type InsertScheduleInput = {
|
|
59
|
+
id: string;
|
|
60
|
+
workflowName: string;
|
|
61
|
+
params: unknown;
|
|
62
|
+
cron?: string | null;
|
|
63
|
+
every?: string | null;
|
|
64
|
+
nextRun: number;
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
};
|
|
67
|
+
export type WorkflowStoreAPI = {
|
|
68
|
+
insertInstance(input: InsertInstanceInput): Promise<InsertInstanceResult>;
|
|
69
|
+
claim(instanceID: string, bootID: string, leaseMs: number, now: number): Promise<WorkflowInstanceRow | null>;
|
|
70
|
+
claimDue(queue: string, limit: number, bootID: string, leaseMs: number, now: number): Promise<Array<WorkflowInstanceRow>>;
|
|
71
|
+
renew(instanceID: string, bootID: string, leaseMs: number, now: number): Promise<boolean>;
|
|
72
|
+
checkpoint(instanceID: string, input: CheckpointInput, now: number): Promise<void>;
|
|
73
|
+
fail(instanceID: string, input: FailInput, now: number, bootID?: string): Promise<boolean>;
|
|
74
|
+
finish(instanceID: string, input: FinishInput, now: number, bootID?: string): Promise<boolean>;
|
|
75
|
+
listRecoverable(now: number): Promise<Array<WorkflowInstanceRow>>;
|
|
76
|
+
countRunning(queue: string, now: number): Promise<number>;
|
|
77
|
+
listRunnableQueues(now: number): Promise<Array<string>>;
|
|
78
|
+
get(instanceID: string): Promise<WorkflowInstanceRow | null>;
|
|
79
|
+
list(filter?: ListFilter): Promise<Array<WorkflowInstanceRow>>;
|
|
80
|
+
insertSchedule(input: InsertScheduleInput, now: number): Promise<void>;
|
|
81
|
+
getSchedule(id: string): Promise<WorkflowScheduleRow | null>;
|
|
82
|
+
listSchedules(): Promise<Array<WorkflowScheduleRow>>;
|
|
83
|
+
listDueSchedules(now: number): Promise<Array<WorkflowScheduleRow>>;
|
|
84
|
+
advanceSchedule(id: string, nextRun: number, now: number): Promise<void>;
|
|
85
|
+
setScheduleEnabled(id: string, enabled: boolean, now: number): Promise<void>;
|
|
86
|
+
};
|
|
87
|
+
export declare function createWorkflowStore(db: Kysely<WorkflowTables>, adapter: Adapter): WorkflowStoreAPI;
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// --- Serialization helpers ---
|
|
2
|
+
// ParseJSONResultsPlugin usually parses JSON columns back to objects, but a
|
|
3
|
+
// value can still arrive as a string (adapter/driver dependent). Handle both.
|
|
4
|
+
function decodeJSON(value) {
|
|
5
|
+
if (value == null) return null;
|
|
6
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
7
|
+
}
|
|
8
|
+
function rowToInstance(row) {
|
|
9
|
+
return {
|
|
10
|
+
...row,
|
|
11
|
+
state: decodeJSON(row.state),
|
|
12
|
+
next_action: decodeJSON(row.next_action)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function rowToSchedule(row) {
|
|
16
|
+
return {
|
|
17
|
+
...row,
|
|
18
|
+
params: decodeJSON(row.params),
|
|
19
|
+
// Stored as integer 0/1 across adapters.
|
|
20
|
+
enabled: Number(row.enabled) === 1
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function createWorkflowStore(db, adapter) {
|
|
24
|
+
// Values common to writing a JSON column across adapters.
|
|
25
|
+
const encodeJSON = (value)=>adapter.encodeJSON(value);
|
|
26
|
+
const encodeNextAction = (value)=>value != null ? adapter.encodeJSON(value) : null;
|
|
27
|
+
const encodeParams = (value)=>adapter.encodeJSON(value);
|
|
28
|
+
async function readRow(tx, instanceID) {
|
|
29
|
+
return await tx.selectFrom('kubun_workflow_instances').selectAll().where('instance_id', '=', instanceID).executeTakeFirst();
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
async insertInstance (input) {
|
|
33
|
+
const queue = input.queue ?? input.name;
|
|
34
|
+
const runAfter = input.runAfter ?? input.createdAt;
|
|
35
|
+
const updatedAt = input.updatedAt ?? input.createdAt;
|
|
36
|
+
// A transaction gives the pre-checks and the insert one snapshot. The
|
|
37
|
+
// partial unique index is the concurrent-race backstop (a second
|
|
38
|
+
// in-flight insert for the same active singleton would surface as a
|
|
39
|
+
// thrown constraint error); the graceful created:false return is the
|
|
40
|
+
// sequential pre-check path, so no driver error strings are parsed.
|
|
41
|
+
return await db.transaction().execute(async (tx)=>{
|
|
42
|
+
if (input.idempotencyKey != null) {
|
|
43
|
+
const existing = await tx.selectFrom('kubun_workflow_instances').selectAll().where('idempotency_key', '=', input.idempotencyKey).executeTakeFirst();
|
|
44
|
+
if (existing != null) {
|
|
45
|
+
return {
|
|
46
|
+
row: rowToInstance(existing),
|
|
47
|
+
created: false,
|
|
48
|
+
reason: 'idempotent'
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (input.singletonKey != null) {
|
|
53
|
+
const active = await tx.selectFrom('kubun_workflow_instances').selectAll().where('name', '=', input.name).where('singleton_key', '=', input.singletonKey).where('status', 'in', [
|
|
54
|
+
'pending',
|
|
55
|
+
'running'
|
|
56
|
+
]).executeTakeFirst();
|
|
57
|
+
if (active != null) {
|
|
58
|
+
return {
|
|
59
|
+
row: rowToInstance(active),
|
|
60
|
+
created: false,
|
|
61
|
+
reason: 'singleton'
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await tx.insertInto('kubun_workflow_instances').values({
|
|
66
|
+
instance_id: input.instanceID,
|
|
67
|
+
name: input.name,
|
|
68
|
+
queue,
|
|
69
|
+
idempotency_key: input.idempotencyKey ?? null,
|
|
70
|
+
singleton_key: input.singletonKey ?? null,
|
|
71
|
+
priority: input.priority ?? 0,
|
|
72
|
+
state: encodeJSON(input.state ?? {}),
|
|
73
|
+
next_action: encodeNextAction(input.nextAction ?? null),
|
|
74
|
+
status: 'pending',
|
|
75
|
+
attempts: 0,
|
|
76
|
+
run_after: runAfter,
|
|
77
|
+
lease_owner: null,
|
|
78
|
+
lease_expires: null,
|
|
79
|
+
last_error: null,
|
|
80
|
+
created_at: input.createdAt,
|
|
81
|
+
updated_at: updatedAt
|
|
82
|
+
}).execute();
|
|
83
|
+
const row = await tx.selectFrom('kubun_workflow_instances').selectAll().where('instance_id', '=', input.instanceID).executeTakeFirstOrThrow();
|
|
84
|
+
return {
|
|
85
|
+
row: rowToInstance(row),
|
|
86
|
+
created: true
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
async claim (instanceID, bootID, leaseMs, now) {
|
|
91
|
+
// One atomic UPDATE. Claimable when pending, or when a running lease has
|
|
92
|
+
// expired (crash reclaim), and the run gate has opened. A concurrent
|
|
93
|
+
// caller either loses the row lock and re-reads the now-running row
|
|
94
|
+
// (WHERE no longer holds) or matches nothing — exactly one winner.
|
|
95
|
+
const row = await db.updateTable('kubun_workflow_instances').set({
|
|
96
|
+
status: 'running',
|
|
97
|
+
lease_owner: bootID,
|
|
98
|
+
lease_expires: now + leaseMs,
|
|
99
|
+
updated_at: now
|
|
100
|
+
}).where('instance_id', '=', instanceID).where('run_after', '<=', now).where((eb)=>eb.or([
|
|
101
|
+
eb('status', '=', 'pending'),
|
|
102
|
+
eb.and([
|
|
103
|
+
eb('status', '=', 'running'),
|
|
104
|
+
eb('lease_expires', '<', now)
|
|
105
|
+
])
|
|
106
|
+
])).returningAll().executeTakeFirst();
|
|
107
|
+
return row != null ? rowToInstance(row) : null;
|
|
108
|
+
},
|
|
109
|
+
async claimDue (queue, limit, bootID, leaseMs, now) {
|
|
110
|
+
// A row is claimable on this lane when its run gate has opened and it is
|
|
111
|
+
// either pending or a running lease that has expired (crash reclaim).
|
|
112
|
+
const claimable = (eb)=>eb.and([
|
|
113
|
+
eb('queue', '=', queue),
|
|
114
|
+
eb('run_after', '<=', now),
|
|
115
|
+
eb.or([
|
|
116
|
+
eb('status', '=', 'pending'),
|
|
117
|
+
eb.and([
|
|
118
|
+
eb('status', '=', 'running'),
|
|
119
|
+
eb('lease_expires', '<', now)
|
|
120
|
+
])
|
|
121
|
+
])
|
|
122
|
+
]);
|
|
123
|
+
// One statement: pick the next `limit` candidates in the inner select, but
|
|
124
|
+
// repeat `claimable` in the OUTER update's WHERE so IT is the atomic guard.
|
|
125
|
+
// Under a concurrent claimDue the loser blocks on the row lock, then
|
|
126
|
+
// re-evaluates the outer WHERE against the now-running row (its own
|
|
127
|
+
// columns, not the snapshot subquery) and skips it — no double-claim,
|
|
128
|
+
// without SKIP LOCKED (which SQLite lacks).
|
|
129
|
+
const rows = await db.updateTable('kubun_workflow_instances').set({
|
|
130
|
+
status: 'running',
|
|
131
|
+
lease_owner: bootID,
|
|
132
|
+
lease_expires: now + leaseMs,
|
|
133
|
+
updated_at: now
|
|
134
|
+
}).where('instance_id', 'in', (eb)=>eb.selectFrom('kubun_workflow_instances').select('instance_id').where(claimable)// priority ascending (lower number first), then earliest run_after.
|
|
135
|
+
.orderBy('priority', 'asc').orderBy('run_after', 'asc').limit(limit)).where(claimable).returningAll().execute();
|
|
136
|
+
// The inner ORDER BY selects WHICH rows; UPDATE ... RETURNING does not
|
|
137
|
+
// guarantee the returned order, so re-sort the batch for the caller
|
|
138
|
+
// (priority ascending, then earliest run_after).
|
|
139
|
+
rows.sort((a, b)=>a.priority - b.priority || a.run_after - b.run_after);
|
|
140
|
+
return rows.map(rowToInstance);
|
|
141
|
+
},
|
|
142
|
+
async renew (instanceID, bootID, leaseMs, now) {
|
|
143
|
+
const row = await db.updateTable('kubun_workflow_instances').set({
|
|
144
|
+
lease_expires: now + leaseMs,
|
|
145
|
+
updated_at: now
|
|
146
|
+
}).where('instance_id', '=', instanceID).where('lease_owner', '=', bootID).where('status', '=', 'running').returning('instance_id').executeTakeFirst();
|
|
147
|
+
return row != null;
|
|
148
|
+
},
|
|
149
|
+
async checkpoint (instanceID, input, now) {
|
|
150
|
+
await db.updateTable('kubun_workflow_instances').set({
|
|
151
|
+
state: encodeJSON(input.state),
|
|
152
|
+
next_action: encodeNextAction(input.nextAction),
|
|
153
|
+
status: input.status ?? 'running',
|
|
154
|
+
updated_at: now
|
|
155
|
+
}).where('instance_id', '=', instanceID).execute();
|
|
156
|
+
},
|
|
157
|
+
async fail (instanceID, input, now, bootID) {
|
|
158
|
+
let query = db.updateTable('kubun_workflow_instances').set({
|
|
159
|
+
state: encodeJSON(input.state),
|
|
160
|
+
next_action: encodeNextAction(input.nextAction),
|
|
161
|
+
last_error: input.error,
|
|
162
|
+
attempts: input.attempts,
|
|
163
|
+
run_after: input.runAfter,
|
|
164
|
+
status: input.status,
|
|
165
|
+
updated_at: now
|
|
166
|
+
}).where('instance_id', '=', instanceID);
|
|
167
|
+
if (bootID != null) query = query.where('lease_owner', '=', bootID);
|
|
168
|
+
const row = await query.returning('instance_id').executeTakeFirst();
|
|
169
|
+
return row != null;
|
|
170
|
+
},
|
|
171
|
+
async finish (instanceID, input, now, bootID) {
|
|
172
|
+
let query = db.updateTable('kubun_workflow_instances').set({
|
|
173
|
+
status: input.status,
|
|
174
|
+
state: encodeJSON(input.state),
|
|
175
|
+
next_action: null,
|
|
176
|
+
lease_owner: null,
|
|
177
|
+
lease_expires: null,
|
|
178
|
+
updated_at: now
|
|
179
|
+
}).where('instance_id', '=', instanceID);
|
|
180
|
+
if (bootID != null) query = query.where('lease_owner', '=', bootID);
|
|
181
|
+
const row = await query.returning('instance_id').executeTakeFirst();
|
|
182
|
+
return row != null;
|
|
183
|
+
},
|
|
184
|
+
async listRecoverable (now) {
|
|
185
|
+
const rows = await db.selectFrom('kubun_workflow_instances').selectAll().where('status', '=', 'running').where('lease_expires', '<', now).execute();
|
|
186
|
+
return rows.map(rowToInstance);
|
|
187
|
+
},
|
|
188
|
+
async countRunning (queue, now) {
|
|
189
|
+
const row = await db.selectFrom('kubun_workflow_instances').select((eb)=>eb.fn.countAll().as('c')).where('queue', '=', queue).where('status', '=', 'running').where('lease_expires', '>=', now).executeTakeFirst();
|
|
190
|
+
return Number(row?.c ?? 0);
|
|
191
|
+
},
|
|
192
|
+
async listRunnableQueues (now) {
|
|
193
|
+
const rows = await db.selectFrom('kubun_workflow_instances').select('queue').distinct().where('run_after', '<=', now).where((eb)=>eb.or([
|
|
194
|
+
eb('status', '=', 'pending'),
|
|
195
|
+
eb.and([
|
|
196
|
+
eb('status', '=', 'running'),
|
|
197
|
+
eb('lease_expires', '<', now)
|
|
198
|
+
])
|
|
199
|
+
])).execute();
|
|
200
|
+
return rows.map((r)=>r.queue);
|
|
201
|
+
},
|
|
202
|
+
async get (instanceID) {
|
|
203
|
+
const row = await readRow(db, instanceID);
|
|
204
|
+
return row != null ? rowToInstance(row) : null;
|
|
205
|
+
},
|
|
206
|
+
async list (filter) {
|
|
207
|
+
let query = db.selectFrom('kubun_workflow_instances').selectAll();
|
|
208
|
+
if (filter?.name != null) query = query.where('name', '=', filter.name);
|
|
209
|
+
if (filter?.queue != null) query = query.where('queue', '=', filter.queue);
|
|
210
|
+
if (filter?.status != null) query = query.where('status', '=', filter.status);
|
|
211
|
+
const rows = await query.execute();
|
|
212
|
+
return rows.map(rowToInstance);
|
|
213
|
+
},
|
|
214
|
+
// --- Schedules ---
|
|
215
|
+
async insertSchedule (input, now) {
|
|
216
|
+
await db.insertInto('kubun_workflow_schedules').values({
|
|
217
|
+
id: input.id,
|
|
218
|
+
workflow_name: input.workflowName,
|
|
219
|
+
params: encodeParams(input.params),
|
|
220
|
+
cron: input.cron ?? null,
|
|
221
|
+
every: input.every ?? null,
|
|
222
|
+
next_run: input.nextRun,
|
|
223
|
+
enabled: input.enabled ? 1 : 0,
|
|
224
|
+
created_at: now,
|
|
225
|
+
updated_at: now
|
|
226
|
+
}).execute();
|
|
227
|
+
},
|
|
228
|
+
async getSchedule (id) {
|
|
229
|
+
const row = await db.selectFrom('kubun_workflow_schedules').selectAll().where('id', '=', id).executeTakeFirst();
|
|
230
|
+
return row != null ? rowToSchedule(row) : null;
|
|
231
|
+
},
|
|
232
|
+
async listSchedules () {
|
|
233
|
+
const rows = await db.selectFrom('kubun_workflow_schedules').selectAll().execute();
|
|
234
|
+
return rows.map(rowToSchedule);
|
|
235
|
+
},
|
|
236
|
+
async listDueSchedules (now) {
|
|
237
|
+
const rows = await db.selectFrom('kubun_workflow_schedules').selectAll().where('enabled', '=', 1).where('next_run', '<=', now).execute();
|
|
238
|
+
return rows.map(rowToSchedule);
|
|
239
|
+
},
|
|
240
|
+
async advanceSchedule (id, nextRun, now) {
|
|
241
|
+
await db.updateTable('kubun_workflow_schedules').set({
|
|
242
|
+
next_run: nextRun,
|
|
243
|
+
updated_at: now
|
|
244
|
+
}).where('id', '=', id).execute();
|
|
245
|
+
},
|
|
246
|
+
async setScheduleEnabled (id, enabled, now) {
|
|
247
|
+
await db.updateTable('kubun_workflow_schedules').set({
|
|
248
|
+
enabled: enabled ? 1 : 0,
|
|
249
|
+
updated_at: now
|
|
250
|
+
}).where('id', '=', id).execute();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
import type { WorkflowStoreAPI } from './api.js';
|
|
3
|
+
export type { CheckpointInput, FailInput, FinishInput, InsertInstanceInput, InsertInstanceResult, InsertReason, InsertScheduleInput, ListFilter, WorkflowInstanceRow, WorkflowNextAction, WorkflowScheduleRow, WorkflowStatus, WorkflowStoreAPI, } from './api.js';
|
|
4
|
+
export { createWorkflowStore } from './api.js';
|
|
5
|
+
export { workflowStoreDefinition } from './definition.js';
|
|
6
|
+
export { WORKFLOW_SINGLETON_UNIQUE_INDEX } from './migrations.js';
|
|
7
|
+
export declare const WORKFLOW_STORE: "workflow";
|
|
8
|
+
export declare function getWorkflowStore(provider: StoreProvider): Promise<WorkflowStoreAPI>;
|
|
9
|
+
export type { WorkflowInstance, WorkflowInstanceTable, WorkflowSchedule, WorkflowScheduleTable, WorkflowTables, } from './tables.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createWorkflowStore } from './api.js';
|
|
2
|
+
export { workflowStoreDefinition } from './definition.js';
|
|
3
|
+
export { WORKFLOW_SINGLETON_UNIQUE_INDEX } from './migrations.js';
|
|
4
|
+
export const WORKFLOW_STORE = 'workflow';
|
|
5
|
+
export function getWorkflowStore(provider) {
|
|
6
|
+
return provider.getStore(WORKFLOW_STORE);
|
|
7
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MigrationContext } from '@kubun/db';
|
|
2
|
+
import type { Migration } from 'kysely/migration';
|
|
3
|
+
export declare const WORKFLOW_SINGLETON_UNIQUE_INDEX = "uq_workflow_instances_singleton";
|
|
4
|
+
export declare function getWorkflowMigrations(ctx: MigrationContext): Record<string, Migration>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
// Partial unique index enforcing at most one active instance per
|
|
3
|
+
// (name, singleton_key). Named here because the guarantee is a database
|
|
4
|
+
// constraint, not a convention a writer upholds. Partial unique indexes carry
|
|
5
|
+
// the same meaning on both adapter families (SQLite >= 3.8, Postgres).
|
|
6
|
+
export const WORKFLOW_SINGLETON_UNIQUE_INDEX = 'uq_workflow_instances_singleton';
|
|
7
|
+
export function getWorkflowMigrations(ctx) {
|
|
8
|
+
const t = ctx.types;
|
|
9
|
+
const init = {
|
|
10
|
+
async up (db) {
|
|
11
|
+
await db.schema.createTable('kubun_workflow_instances').ifNotExists().addColumn('instance_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('queue', t.text, (col)=>col.notNull()).addColumn('idempotency_key', t.text).addColumn('singleton_key', t.text).addColumn('priority', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('state', t.json, (col)=>col.notNull()).addColumn('next_action', t.json).addColumn('status', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull().defaultTo(0))// ms-epoch as bigint (not the `timestamp` column type). See tables.ts:
|
|
12
|
+
// the store compares against an explicit numeric `now`, and `timestamp`
|
|
13
|
+
// diverges across adapters. bigint holds ms-epoch and the int8 parser
|
|
14
|
+
// reads it back as a JS number on both.
|
|
15
|
+
.addColumn('run_after', t.bigint, (col)=>col.notNull()).addColumn('lease_owner', t.text).addColumn('lease_expires', t.bigint).addColumn('last_error', t.text).addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).execute();
|
|
16
|
+
// Exactly-once enqueue. A plain unique index treats NULLs as distinct on
|
|
17
|
+
// both adapters (Postgres default, SQLite always), so many rows may carry
|
|
18
|
+
// a null idempotency_key while present keys stay unique.
|
|
19
|
+
await db.schema.createIndex('uq_workflow_instances_idempotency').ifNotExists().unique().on('kubun_workflow_instances').column('idempotency_key').execute();
|
|
20
|
+
// At most one active instance per (name, singleton_key). Partial: only
|
|
21
|
+
// pending/running rows and non-null keys participate, so terminal rows
|
|
22
|
+
// free the key for a fresh singleton.
|
|
23
|
+
await db.schema.createIndex(WORKFLOW_SINGLETON_UNIQUE_INDEX).ifNotExists().unique().on('kubun_workflow_instances').columns([
|
|
24
|
+
'name',
|
|
25
|
+
'singleton_key'
|
|
26
|
+
])// Raw predicate: the index builder's expression scope is limited to the
|
|
27
|
+
// indexed columns, so `status` is referenced via raw sql (the store-p2p
|
|
28
|
+
// partial-index precedent). Portable across SQLite and Postgres.
|
|
29
|
+
.where(sql`status in ('pending', 'running') and singleton_key is not null`).execute();
|
|
30
|
+
// Claim scan: pick the next runnable instance on a lane.
|
|
31
|
+
await db.schema.createIndex('idx_workflow_instances_claim').ifNotExists().on('kubun_workflow_instances').columns([
|
|
32
|
+
'queue',
|
|
33
|
+
'status',
|
|
34
|
+
'run_after'
|
|
35
|
+
]).execute();
|
|
36
|
+
// Recovery scan: strand-reclaim of running rows past their lease.
|
|
37
|
+
await db.schema.createIndex('idx_workflow_instances_recovery').ifNotExists().on('kubun_workflow_instances').columns([
|
|
38
|
+
'status',
|
|
39
|
+
'lease_expires'
|
|
40
|
+
]).execute();
|
|
41
|
+
await db.schema.createTable('kubun_workflow_schedules').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('workflow_name', t.text, (col)=>col.notNull()).addColumn('params', t.json, (col)=>col.notNull())// Exactly one of cron / every is set; both nullable, the store persists
|
|
42
|
+
// them without parsing.
|
|
43
|
+
.addColumn('cron', t.text).addColumn('every', t.text)// ms-epoch as bigint (not the `timestamp` column type), matching the
|
|
44
|
+
// instance time columns and compared against an explicit numeric `now`.
|
|
45
|
+
.addColumn('next_run', t.bigint, (col)=>col.notNull())// 0/1: no portable boolean column type across the adapters.
|
|
46
|
+
.addColumn('enabled', 'integer', (col)=>col.notNull().defaultTo(1)).addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('updated_at', t.bigint, (col)=>col.notNull()).execute();
|
|
47
|
+
// Due-scan: enabled schedules whose next_run has arrived.
|
|
48
|
+
await db.schema.createIndex('idx_workflow_schedules_due').ifNotExists().on('kubun_workflow_schedules').columns([
|
|
49
|
+
'enabled',
|
|
50
|
+
'next_run'
|
|
51
|
+
]).execute();
|
|
52
|
+
},
|
|
53
|
+
async down (db) {
|
|
54
|
+
await db.schema.dropIndex('idx_workflow_schedules_due').ifExists().execute();
|
|
55
|
+
await db.schema.dropTable('kubun_workflow_schedules').ifExists().execute();
|
|
56
|
+
await db.schema.dropIndex('idx_workflow_instances_recovery').ifExists().execute();
|
|
57
|
+
await db.schema.dropIndex('idx_workflow_instances_claim').ifExists().execute();
|
|
58
|
+
await db.schema.dropIndex(WORKFLOW_SINGLETON_UNIQUE_INDEX).ifExists().execute();
|
|
59
|
+
await db.schema.dropIndex('uq_workflow_instances_idempotency').ifExists().execute();
|
|
60
|
+
await db.schema.dropTable('kubun_workflow_instances').ifExists().execute();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
'0-init': init
|
|
65
|
+
};
|
|
66
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Selectable } from 'kysely';
|
|
2
|
+
export type WorkflowInstanceTable = {
|
|
3
|
+
instance_id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
queue: string;
|
|
6
|
+
idempotency_key: string | null;
|
|
7
|
+
singleton_key: string | null;
|
|
8
|
+
priority: number;
|
|
9
|
+
state: unknown;
|
|
10
|
+
next_action: unknown | null;
|
|
11
|
+
status: string;
|
|
12
|
+
attempts: number;
|
|
13
|
+
run_after: number;
|
|
14
|
+
lease_owner: string | null;
|
|
15
|
+
lease_expires: number | null;
|
|
16
|
+
last_error: string | null;
|
|
17
|
+
created_at: number;
|
|
18
|
+
updated_at: number;
|
|
19
|
+
};
|
|
20
|
+
export type WorkflowInstance = Selectable<WorkflowInstanceTable>;
|
|
21
|
+
export type WorkflowScheduleTable = {
|
|
22
|
+
id: string;
|
|
23
|
+
workflow_name: string;
|
|
24
|
+
params: unknown;
|
|
25
|
+
cron: string | null;
|
|
26
|
+
every: string | null;
|
|
27
|
+
next_run: number;
|
|
28
|
+
enabled: number;
|
|
29
|
+
created_at: number;
|
|
30
|
+
updated_at: number;
|
|
31
|
+
};
|
|
32
|
+
export type WorkflowSchedule = Selectable<WorkflowScheduleTable>;
|
|
33
|
+
export type WorkflowTables = {
|
|
34
|
+
kubun_workflow_instances: WorkflowInstanceTable;
|
|
35
|
+
kubun_workflow_schedules: WorkflowScheduleTable;
|
|
36
|
+
};
|
package/lib/tables.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kubun/store-workflow",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"keywords": [],
|
|
5
|
+
"license": "see LICENSE.md",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/*",
|
|
15
|
+
"LICENSE.md"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"kysely": "^0.29.5",
|
|
19
|
+
"@kubun/db": "^0.13.0",
|
|
20
|
+
"@kubun/db-adapter": "^0.13.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
24
|
+
"@kubun/db-better-sqlite": "^0.13.0",
|
|
25
|
+
"@kubun/db-postgres": "^0.13.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
32
|
+
"build:clean": "del lib",
|
|
33
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
34
|
+
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
35
|
+
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
36
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
37
|
+
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
38
|
+
"test:unit": "vitest run"
|
|
39
|
+
}
|
|
40
|
+
}
|