@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 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
+ }
@@ -0,0 +1,4 @@
1
+ import type { StoreDefinition } from '@kubun/db';
2
+ import type { WorkflowStoreAPI } from './api.js';
3
+ import type { WorkflowTables } from './tables.js';
4
+ export declare const workflowStoreDefinition: StoreDefinition<WorkflowTables, WorkflowStoreAPI>;
@@ -0,0 +1,7 @@
1
+ import { createWorkflowStore } from './api.js';
2
+ import { getWorkflowMigrations } from './migrations.js';
3
+ export const workflowStoreDefinition = {
4
+ name: 'workflow',
5
+ migrations: getWorkflowMigrations,
6
+ createAPI: createWorkflowStore
7
+ };
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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ // --- Aggregate WorkflowTables type ---
2
+ export { };
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
+ }