@yeaft/webchat-agent 1.0.567 → 1.0.568
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +229 -120
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/work-center/attachments.js +28 -0
- package/yeaft/work-center/bridge.js +3 -2
- package/yeaft/work-center/controller.js +3 -3
- package/yeaft/work-center/durable-model.js +18 -10
- package/yeaft/work-center/projection.js +27 -3
- package/yeaft/work-center/recurrence.js +103 -0
- package/yeaft/work-center/resource-control.js +2 -4
- package/yeaft/work-center/service.js +30 -3
- package/yeaft/work-center/store.js +137 -24
- package/yeaft/work-center/transaction.js +21 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { normalizeRecurrence, validateScheduleTimestamp, initialOccurrence, nextOccurrence, latestOccurrence } from './recurrence.js';
|
|
1
2
|
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { withTransaction } from './transaction.js';
|
|
2
4
|
import { mkdirSync, realpathSync } from 'node:fs';
|
|
3
5
|
import { dirname, resolve } from 'node:path';
|
|
4
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -114,10 +116,15 @@ function mapWorkItem(row) {
|
|
|
114
116
|
deliveryTarget: row.delivery_target || null,
|
|
115
117
|
deliveryInstructions: row.delivery_instructions || '',
|
|
116
118
|
schedule: row.schedule_status ? {
|
|
117
|
-
status: row.schedule_status,
|
|
119
|
+
status: row.status === 'cancelled' ? 'cancelled' : row.schedule_status,
|
|
118
120
|
scheduledFor: Number(row.scheduled_for) || null,
|
|
119
121
|
triggeredAt: Number(row.schedule_triggered_at) || null,
|
|
122
|
+
recurrence: parseJson(row.schedule_recurrence, null),
|
|
123
|
+
runCount: Number(row.schedule_run_count) || 0,
|
|
124
|
+
lastWorkItemId: row.schedule_last_work_item_id || null,
|
|
120
125
|
} : null,
|
|
126
|
+
sourceScheduleId: row.source_schedule_id || null,
|
|
127
|
+
scheduledOccurrenceAt: row.scheduled_occurrence_at ?? null,
|
|
121
128
|
title: row.title,
|
|
122
129
|
titleSource: row.title_source || 'explicit',
|
|
123
130
|
requirement: row.requirement ?? row.goal,
|
|
@@ -341,18 +348,6 @@ function mapEvent(row) {
|
|
|
341
348
|
};
|
|
342
349
|
}
|
|
343
350
|
|
|
344
|
-
function withTransaction(db, fn) {
|
|
345
|
-
db.exec('BEGIN IMMEDIATE');
|
|
346
|
-
try {
|
|
347
|
-
const result = fn();
|
|
348
|
-
db.exec('COMMIT');
|
|
349
|
-
return result;
|
|
350
|
-
} catch (err) {
|
|
351
|
-
try { db.exec('ROLLBACK'); } catch {}
|
|
352
|
-
throw err;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
351
|
function hasColumn(db, table, column) {
|
|
357
352
|
return db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column);
|
|
358
353
|
}
|
|
@@ -841,7 +836,8 @@ export class WorkItemStore {
|
|
|
841
836
|
extendExecutionBudget(id, revision, additions) { return this.resourceControl.extend(id, revision, additions); }
|
|
842
837
|
stopExecution(id, code, details) { return this.resourceControl.stop(id, code, details); }
|
|
843
838
|
canAutomaticallyCoordinate(id, { userMessage = false } = {}) {
|
|
844
|
-
const row = this.db.prepare('SELECT status FROM work_items WHERE id = ?').get(id);
|
|
839
|
+
const row = this.db.prepare('SELECT status, schedule_recurrence FROM work_items WHERE id = ?').get(id);
|
|
840
|
+
if (row?.schedule_recurrence && parseJson(row.schedule_recurrence, null)) return false;
|
|
845
841
|
if (!row || ['done', 'cancelled'].includes(row.status) || this.isExecutionStopped(id)) return false;
|
|
846
842
|
const control = this.getExecutionControl(id);
|
|
847
843
|
// A failed Action may be closed without retrying it. Enforce lifetime
|
|
@@ -1364,7 +1360,7 @@ export class WorkItemStore {
|
|
|
1364
1360
|
);
|
|
1365
1361
|
return this.db.prepare('SELECT * FROM action_entries WHERE id = ?').get(id);
|
|
1366
1362
|
};
|
|
1367
|
-
return
|
|
1363
|
+
return withTransaction(this.db, append);
|
|
1368
1364
|
}
|
|
1369
1365
|
|
|
1370
1366
|
appendActionControl(workItemId, actionId, command, options = {}) {
|
|
@@ -1403,7 +1399,7 @@ export class WorkItemStore {
|
|
|
1403
1399
|
);
|
|
1404
1400
|
return this.db.prepare('SELECT * FROM coordinator_mailbox_entries WHERE id = ?').get(id);
|
|
1405
1401
|
};
|
|
1406
|
-
return
|
|
1402
|
+
return withTransaction(this.db, enqueue);
|
|
1407
1403
|
}
|
|
1408
1404
|
|
|
1409
1405
|
claimCoordinatorTurn(workItemId, turnId, owner, leaseMs = 60_000) {
|
|
@@ -2428,6 +2424,9 @@ export class WorkItemStore {
|
|
|
2428
2424
|
const now = this.now();
|
|
2429
2425
|
const id = input.id || randomUUID();
|
|
2430
2426
|
const workspaceKey = canonicalWorkspaceKey(input.workDir);
|
|
2427
|
+
const recurrence = normalizeRecurrence(input.schedule?.recurrence);
|
|
2428
|
+
if (recurrence && firstAction) throw new Error('A recurring schedule must remain a draft plan');
|
|
2429
|
+
if (input.schedule) validateScheduleTimestamp(input.schedule.scheduledFor);
|
|
2431
2430
|
this.db.prepare(`INSERT INTO work_items
|
|
2432
2431
|
(id, revision, execution_schema_version, ledger_revision, coordination_mode, final_result, delivery_target, delivery_instructions,
|
|
2433
2432
|
schedule_status, scheduled_for, schedule_triggered_at, title, title_source, requirement, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
|
|
@@ -2459,6 +2458,12 @@ export class WorkItemStore {
|
|
|
2459
2458
|
now,
|
|
2460
2459
|
now,
|
|
2461
2460
|
);
|
|
2461
|
+
if (recurrence) {
|
|
2462
|
+
const due = initialOccurrence(recurrence, input.schedule.scheduledFor);
|
|
2463
|
+
const completed = due == null || (recurrence.endsAt != null && (recurrence.endsAt < now || due > recurrence.endsAt));
|
|
2464
|
+
this.db.prepare(`UPDATE work_items SET schedule_recurrence = ?, scheduled_for = ?, schedule_status = ? WHERE id = ?`)
|
|
2465
|
+
.run(stringify(recurrence), due, completed ? 'completed' : input.schedule.status, id);
|
|
2466
|
+
}
|
|
2462
2467
|
let action = null;
|
|
2463
2468
|
if (firstAction) {
|
|
2464
2469
|
action = this.#insertAction(id, { ...firstAction, contractRevision: 1 }, 1, now);
|
|
@@ -3090,6 +3095,7 @@ export class WorkItemStore {
|
|
|
3090
3095
|
|
|
3091
3096
|
getWorkItem(id) {
|
|
3092
3097
|
const workItem = mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
|
|
3098
|
+
if (workItem?.schedule) workItem.schedule.lastError = this.getScheduleLastError(id);
|
|
3093
3099
|
if (!usesLegacyGraph(workItem) && !isDynamicWorkItem(workItem)) return workItem;
|
|
3094
3100
|
const actions = this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence')
|
|
3095
3101
|
.all(id).map(mapAction);
|
|
@@ -3135,12 +3141,96 @@ export class WorkItemStore {
|
|
|
3135
3141
|
}));
|
|
3136
3142
|
}
|
|
3137
3143
|
|
|
3144
|
+
// Event IDs, not timestamps, order failures and clearing transitions (which may
|
|
3145
|
+
// share a clock tick). Unrelated plan edits must not hide a dispatch failure.
|
|
3146
|
+
getScheduleLastError(id) {
|
|
3147
|
+
const event = this.db.prepare(`SELECT type, created_at FROM events WHERE work_item_id = ?
|
|
3148
|
+
AND type IN ('work_item.schedule_failed', 'work_item.schedule_triggered',
|
|
3149
|
+
'work_item.schedule_advanced', 'work_item.schedule_updated', 'work_item.started')
|
|
3150
|
+
ORDER BY id DESC LIMIT 1`).get(id);
|
|
3151
|
+
return event?.type === 'work_item.schedule_failed' ? {
|
|
3152
|
+
code: 'schedule_dispatch_failed',
|
|
3153
|
+
message: 'Scheduled execution could not start. The plan will retry automatically; check its configuration and attachments.',
|
|
3154
|
+
at: Number(event.created_at),
|
|
3155
|
+
} : null;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
recordScheduleFailure(id) {
|
|
3159
|
+
return withTransaction(this.db, () => {
|
|
3160
|
+
const workItem = this.getWorkItem(id);
|
|
3161
|
+
if (workItem?.status !== 'draft' || workItem.schedule?.status !== 'scheduled'
|
|
3162
|
+
|| workItem.schedule.lastError) return null;
|
|
3163
|
+
// One durable notification per uninterrupted failure episode, even across
|
|
3164
|
+
// restarts. Never persist exception text: it may contain paths or secrets.
|
|
3165
|
+
this.appendEvent(id, 'work_item.schedule_failed', { code: 'schedule_dispatch_failed' });
|
|
3166
|
+
this.db.prepare('UPDATE work_items SET revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
3167
|
+
.run(this.now(), id);
|
|
3168
|
+
return this.getWorkItemDetail(id);
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3138
3172
|
listDueScheduledWorkItemIds(now = this.now()) {
|
|
3139
3173
|
return this.db.prepare(`SELECT id FROM work_items
|
|
3140
3174
|
WHERE schedule_status = 'scheduled' AND scheduled_for <= ? AND status = 'draft'
|
|
3141
3175
|
ORDER BY scheduled_for, id`).all(now).map(row => row.id);
|
|
3142
3176
|
}
|
|
3143
3177
|
|
|
3178
|
+
dispatchScheduledWorkItem(id, scheduledAt, makeInitialAction, cloneAttachments = null) {
|
|
3179
|
+
validateScheduleTimestamp(scheduledAt);
|
|
3180
|
+
return withTransaction(this.db, () => {
|
|
3181
|
+
const source = this.getWorkItem(id);
|
|
3182
|
+
if (!source || source.status !== 'draft' || source.schedule?.status !== 'scheduled'
|
|
3183
|
+
|| source.schedule.scheduledFor > scheduledAt) return null;
|
|
3184
|
+
const schedule = source.schedule;
|
|
3185
|
+
if (!schedule.recurrence) return this.startWorkItemAtomic(id, makeInitialAction, { scheduledAt });
|
|
3186
|
+
const recurrence = normalizeRecurrence(schedule.recurrence);
|
|
3187
|
+
const expired = recurrence.endsAt != null && scheduledAt > recurrence.endsAt;
|
|
3188
|
+
const exhausted = recurrence.maxRuns != null && schedule.runCount >= recurrence.maxRuns;
|
|
3189
|
+
const due = latestOccurrence(recurrence, scheduledAt);
|
|
3190
|
+
const next = nextOccurrence(recurrence, scheduledAt);
|
|
3191
|
+
// failed Actions / Runs do not mean a terminal WorkItem: needs_attention,
|
|
3192
|
+
// waiting and running remain live and may be retried by their owner.
|
|
3193
|
+
const overlapping = this.db.prepare(`SELECT id FROM work_items WHERE source_schedule_id = ?
|
|
3194
|
+
AND status NOT IN ('done', 'cancelled', 'failed', 'error') LIMIT 1`).get(id);
|
|
3195
|
+
let occurrence = null;
|
|
3196
|
+
if (!expired && !exhausted && !overlapping && due != null && due >= schedule.scheduledFor) {
|
|
3197
|
+
const occurrenceId = randomUUID();
|
|
3198
|
+
const attachments = source.attachments.length
|
|
3199
|
+
? cloneAttachments?.(source, occurrenceId) : [];
|
|
3200
|
+
if (!attachments) throw new Error('Recurring attachments require an owner-safe clone');
|
|
3201
|
+
this.createWorkItem({
|
|
3202
|
+
id: occurrenceId, title: source.title, titleSource: source.titleSource, goal: source.goal,
|
|
3203
|
+
acceptanceCriteria: source.acceptanceCriteria, workflowTemplate: source.workflowTemplate,
|
|
3204
|
+
workflowSnapshot: source.workflowSnapshot, executionSchemaVersion: source.executionSchemaVersion,
|
|
3205
|
+
coordinationMode: source.coordinationMode, workDir: source.workDir, reuseMemory: source.reuseMemory,
|
|
3206
|
+
origin: source.origin, linkedSessionIds: source.linkedSessionIds, sessionContext: source.sessionContext,
|
|
3207
|
+
deliveryTarget: source.deliveryTarget, deliveryInstructions: source.deliveryInstructions, attachments,
|
|
3208
|
+
});
|
|
3209
|
+
this.db.prepare(`UPDATE work_items SET source_schedule_id = ?, scheduled_occurrence_at = ?, requirement = ? WHERE id = ?`)
|
|
3210
|
+
.run(id, due, source.requirement, occurrenceId);
|
|
3211
|
+
// Copy the plan's approved limits, not usage, stops, reservations or claims.
|
|
3212
|
+
this.db.prepare(`INSERT INTO work_item_execution_controls (work_item_id, limits_json, action_attempts_extension)
|
|
3213
|
+
SELECT ?, limits_json, action_attempts_extension FROM work_item_execution_controls WHERE work_item_id = ?
|
|
3214
|
+
ON CONFLICT(work_item_id) DO UPDATE SET limits_json = excluded.limits_json,
|
|
3215
|
+
action_attempts_extension = excluded.action_attempts_extension`).run(occurrenceId, id);
|
|
3216
|
+
occurrence = this.startWorkItemAtomic(occurrenceId, makeInitialAction);
|
|
3217
|
+
}
|
|
3218
|
+
const runCount = schedule.runCount + (occurrence ? 1 : 0);
|
|
3219
|
+
const completed = expired || exhausted || next == null
|
|
3220
|
+
|| (recurrence.endsAt != null && next > recurrence.endsAt)
|
|
3221
|
+
|| (recurrence.maxRuns != null && runCount >= recurrence.maxRuns);
|
|
3222
|
+
this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?, schedule_run_count = ?,
|
|
3223
|
+
schedule_last_work_item_id = ?, schedule_triggered_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
3224
|
+
.run(completed ? 'completed' : 'scheduled', next, runCount, occurrence?.id || schedule.lastWorkItemId,
|
|
3225
|
+
occurrence ? scheduledAt : schedule.triggeredAt, this.now(), id);
|
|
3226
|
+
this.appendEvent(id, occurrence ? 'work_item.schedule_triggered' : 'work_item.schedule_advanced', {
|
|
3227
|
+
occurrenceId: occurrence?.id || null, scheduledOccurrenceAt: occurrence?.scheduledOccurrenceAt || null,
|
|
3228
|
+
nextScheduledFor: next, status: completed ? 'completed' : 'scheduled', skippedOverlap: !!overlapping,
|
|
3229
|
+
});
|
|
3230
|
+
return occurrence;
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3144
3234
|
updateWorkItemSchedule(id, input = {}) {
|
|
3145
3235
|
return withTransaction(this.db, () => {
|
|
3146
3236
|
const row = this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id);
|
|
@@ -3148,15 +3238,30 @@ export class WorkItemStore {
|
|
|
3148
3238
|
if (row.status !== 'draft' || !['scheduled', 'paused'].includes(row.schedule_status)) {
|
|
3149
3239
|
throw new Error('Only pending scheduled WorkItems can be changed');
|
|
3150
3240
|
}
|
|
3151
|
-
|
|
3152
|
-
if (
|
|
3153
|
-
throw new Error('
|
|
3241
|
+
if (typeof input.enabled !== 'boolean') throw new Error('enabled must be a boolean');
|
|
3242
|
+
if (input.revision !== undefined && input.revision !== row.revision) {
|
|
3243
|
+
throw new Error('Schedule changed; refresh and try again');
|
|
3154
3244
|
}
|
|
3155
|
-
const
|
|
3245
|
+
const oldRecurrence = parseJson(row.schedule_recurrence, null);
|
|
3246
|
+
const recurrence = Object.hasOwn(input, 'recurrence') ? normalizeRecurrence(input.recurrence) : oldRecurrence;
|
|
3247
|
+
if (oldRecurrence && !recurrence) throw new Error('A recurring plan cannot be converted to a one-shot WorkItem');
|
|
3156
3248
|
const now = this.now();
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3249
|
+
let scheduledFor = Object.hasOwn(input, 'scheduledFor')
|
|
3250
|
+
? validateScheduleTimestamp(input.scheduledFor) : Number(row.scheduled_for);
|
|
3251
|
+
if (Object.hasOwn(input, 'scheduledFor') && scheduledFor <= now) throw new Error('scheduledFor must be in the future');
|
|
3252
|
+
if (recurrence) {
|
|
3253
|
+
scheduledFor = initialOccurrence(recurrence, scheduledFor);
|
|
3254
|
+
if (input.enabled && scheduledFor != null && scheduledFor <= now) scheduledFor = nextOccurrence(recurrence, now);
|
|
3255
|
+
} else if (input.enabled && scheduledFor <= now) {
|
|
3256
|
+
throw new Error('Overdue one-shot schedule requires a future scheduledFor to resume');
|
|
3257
|
+
}
|
|
3258
|
+
const completed = recurrence && (scheduledFor == null
|
|
3259
|
+
|| (recurrence.endsAt != null && (recurrence.endsAt < now || scheduledFor > recurrence.endsAt))
|
|
3260
|
+
|| (recurrence.maxRuns != null && row.schedule_run_count >= recurrence.maxRuns));
|
|
3261
|
+
const status = completed ? 'completed' : input.enabled ? 'scheduled' : 'paused';
|
|
3262
|
+
this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?, schedule_recurrence = ?,
|
|
3263
|
+
revision = revision + 1, updated_at = ? WHERE id = ?`).run(status, scheduledFor, stringify(recurrence), now, id);
|
|
3264
|
+
this.appendEvent(id, 'work_item.schedule_updated', { status, scheduledFor, recurrence });
|
|
3160
3265
|
return this.getWorkItemDetail(id);
|
|
3161
3266
|
});
|
|
3162
3267
|
}
|
|
@@ -3261,6 +3366,7 @@ export class WorkItemStore {
|
|
|
3261
3366
|
runsByWorkItem.get(row.work_item_id).push(mapRun(row));
|
|
3262
3367
|
}
|
|
3263
3368
|
return workItems.map(workItem => {
|
|
3369
|
+
if (workItem.schedule) workItem.schedule.lastError = this.getScheduleLastError(workItem.id);
|
|
3264
3370
|
const actions = actionsByWorkItem.get(workItem.id) || [];
|
|
3265
3371
|
return graphExecutionState({ ...workItem, executionControl: this.getExecutionControl(workItem.id), actions, runs: runsByWorkItem.get(workItem.id) || [] }, actions);
|
|
3266
3372
|
});
|
|
@@ -3454,6 +3560,7 @@ export class WorkItemStore {
|
|
|
3454
3560
|
}
|
|
3455
3561
|
const workItem = this.getWorkItem(id);
|
|
3456
3562
|
if (!workItem) return null;
|
|
3563
|
+
if (workItem.schedule?.recurrence) throw new Error('Recurring schedule plans cannot accept Coordinator messages; edit the plan instead');
|
|
3457
3564
|
if (this.isExecutionStopped(id)) throw new WorkCenterResourceStopError(this.getExecutionControl(id).stopReason);
|
|
3458
3565
|
if (['done', 'cancelled'].includes(workItem.status)) {
|
|
3459
3566
|
throw new Error(`WorkItem in ${workItem.status} cannot accept Coordinator messages`);
|
|
@@ -4375,8 +4482,11 @@ export class WorkItemStore {
|
|
|
4375
4482
|
now,
|
|
4376
4483
|
id,
|
|
4377
4484
|
);
|
|
4485
|
+
if (current.schedule?.recurrence && next.goal !== current.goal) {
|
|
4486
|
+
this.db.prepare('UPDATE work_items SET requirement = ? WHERE id = ?').run(next.goal, id);
|
|
4487
|
+
}
|
|
4378
4488
|
let action = null;
|
|
4379
|
-
if (contractChanged) {
|
|
4489
|
+
if (contractChanged && !current.schedule?.recurrence) {
|
|
4380
4490
|
const updated = this.getWorkItem(id);
|
|
4381
4491
|
if (isDynamicWorkItem(updated)) {
|
|
4382
4492
|
this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = NULL,
|
|
@@ -4416,6 +4526,7 @@ export class WorkItemStore {
|
|
|
4416
4526
|
now,
|
|
4417
4527
|
);
|
|
4418
4528
|
this.db.prepare(`UPDATE work_items SET status = 'cancelled', current_action_id = NULL,
|
|
4529
|
+
schedule_status = CASE WHEN schedule_status IN ('scheduled', 'paused') THEN 'paused' ELSE schedule_status END,
|
|
4419
4530
|
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
|
4420
4531
|
this.appendEvent(id, 'work_item.cancelled');
|
|
4421
4532
|
return this.getWorkItem(id);
|
|
@@ -4426,6 +4537,7 @@ export class WorkItemStore {
|
|
|
4426
4537
|
return withTransaction(this.db, () => {
|
|
4427
4538
|
const workItem = this.getWorkItem(id);
|
|
4428
4539
|
if (!workItem) return null;
|
|
4540
|
+
if (workItem.schedule?.recurrence) throw new Error('Cannot resume execution of a recurring schedule plan');
|
|
4429
4541
|
if (!Number.isInteger(expectedRevision) || workItem.revision !== expectedRevision) {
|
|
4430
4542
|
throw new Error('WorkItem changed before it was resumed; refresh and try again');
|
|
4431
4543
|
}
|
|
@@ -4558,6 +4670,7 @@ export class WorkItemStore {
|
|
|
4558
4670
|
return withTransaction(this.db, () => {
|
|
4559
4671
|
const workItem = this.getWorkItem(id);
|
|
4560
4672
|
if (!workItem) return null;
|
|
4673
|
+
if (workItem.schedule?.recurrence) throw new Error('Cannot manually start a recurring schedule plan');
|
|
4561
4674
|
const scheduledAt = Number(options.scheduledAt);
|
|
4562
4675
|
const scheduledStart = Number.isSafeInteger(scheduledAt);
|
|
4563
4676
|
if (scheduledStart && (workItem.status !== 'draft' || workItem.schedule?.status !== 'scheduled'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run synchronous database work atomically, including inside a caller-owned
|
|
5
|
+
* transaction. SAVEPOINT works without DatabaseSync.isTransaction (Node 22.5).
|
|
6
|
+
* Releasing an outermost savepoint commits; nested releases never commit the
|
|
7
|
+
* caller's transaction. Unlike BEGIN IMMEDIATE, the write lock is acquired on
|
|
8
|
+
* the first write, so callbacks must not rely on holding it before then.
|
|
9
|
+
*/
|
|
10
|
+
export function withTransaction(db, fn) {
|
|
11
|
+
const savepoint = `wc_${randomUUID().replaceAll('-', '')}`;
|
|
12
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
13
|
+
try {
|
|
14
|
+
const result = fn();
|
|
15
|
+
db.exec(`RELEASE ${savepoint}`);
|
|
16
|
+
return result;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
try { db.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`); } catch {}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|