@spooky-sync/core 0.0.1-canary.111 → 0.0.1-canary.113
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/dist/index.d.ts +49 -0
- package/dist/index.js +88 -4
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/index.ts +123 -2
- package/src/sp00ky.ts +28 -0
- package/src/types.ts +6 -0
package/dist/index.d.ts
CHANGED
|
@@ -543,6 +543,45 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
543
543
|
*/
|
|
544
544
|
notifyQuerySynced(queryHash: string): Promise<void>;
|
|
545
545
|
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, data: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
|
|
546
|
+
/**
|
|
547
|
+
* Build the outbox job record + resolve its table for a backend route. Shared
|
|
548
|
+
* by `run` (one-shot) and `runRecurring` (durable schedule).
|
|
549
|
+
*/
|
|
550
|
+
private buildJobRecord;
|
|
551
|
+
/**
|
|
552
|
+
* Deterministic id for the single recurring-schedule row of a given
|
|
553
|
+
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
554
|
+
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
555
|
+
*/
|
|
556
|
+
private recurringJobId;
|
|
557
|
+
/**
|
|
558
|
+
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
559
|
+
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
560
|
+
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
561
|
+
* this on every connect/re-login never forks a second schedule. The first run
|
|
562
|
+
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
563
|
+
*/
|
|
564
|
+
runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, data: RoutePayload<S, B, R>, options: RunOptions & {
|
|
565
|
+
interval: number;
|
|
566
|
+
assignedTo: string;
|
|
567
|
+
}): Promise<void>;
|
|
568
|
+
/**
|
|
569
|
+
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
570
|
+
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
571
|
+
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
572
|
+
* from that run's completion. No-op if no schedule exists (caller should have
|
|
573
|
+
* created one via `runRecurring`).
|
|
574
|
+
*/
|
|
575
|
+
pokeRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
576
|
+
assignedTo: string;
|
|
577
|
+
}): Promise<void>;
|
|
578
|
+
/**
|
|
579
|
+
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
580
|
+
* being dispatched server-side.
|
|
581
|
+
*/
|
|
582
|
+
cancelRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
583
|
+
assignedTo: string;
|
|
584
|
+
}): Promise<void>;
|
|
546
585
|
/**
|
|
547
586
|
* Create a new record
|
|
548
587
|
*/
|
|
@@ -1241,6 +1280,16 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1241
1280
|
*/
|
|
1242
1281
|
reportFrontendTiming(queryHash: string, ms: number): void;
|
|
1243
1282
|
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
|
|
1283
|
+
runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options: RunOptions & {
|
|
1284
|
+
interval: number;
|
|
1285
|
+
assignedTo: string;
|
|
1286
|
+
}): Promise<void>;
|
|
1287
|
+
pokeRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
1288
|
+
assignedTo: string;
|
|
1289
|
+
}): Promise<void>;
|
|
1290
|
+
cancelRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
1291
|
+
assignedTo: string;
|
|
1292
|
+
}): Promise<void>;
|
|
1244
1293
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle;
|
|
1245
1294
|
create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
1246
1295
|
update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
|
package/dist/index.js
CHANGED
|
@@ -2927,6 +2927,15 @@ var DataModule = class {
|
|
|
2927
2927
|
}
|
|
2928
2928
|
}
|
|
2929
2929
|
async run(backend, path, data, options) {
|
|
2930
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
2931
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
2932
|
+
await this.create(recordId, record);
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Build the outbox job record + resolve its table for a backend route. Shared
|
|
2936
|
+
* by `run` (one-shot) and `runRecurring` (durable schedule).
|
|
2937
|
+
*/
|
|
2938
|
+
buildJobRecord(backend, path, data, options) {
|
|
2930
2939
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
2931
2940
|
if (!route) throw new Error(`Route ${backend}.${path} not found`);
|
|
2932
2941
|
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
@@ -2946,8 +2955,74 @@ var DataModule = class {
|
|
|
2946
2955
|
if (options?.timeout != null) record.timeout = options.timeout;
|
|
2947
2956
|
if (options?.delay != null) record.delay = options.delay;
|
|
2948
2957
|
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
2949
|
-
|
|
2950
|
-
|
|
2958
|
+
return {
|
|
2959
|
+
tableName,
|
|
2960
|
+
record
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Deterministic id for the single recurring-schedule row of a given
|
|
2965
|
+
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
2966
|
+
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
2967
|
+
*/
|
|
2968
|
+
recurringJobId(tableName, assignedTo, path) {
|
|
2969
|
+
return `${tableName}:${`${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
|
|
2970
|
+
}
|
|
2971
|
+
/**
|
|
2972
|
+
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
2973
|
+
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
2974
|
+
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
2975
|
+
* this on every connect/re-login never forks a second schedule. The first run
|
|
2976
|
+
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
2977
|
+
*/
|
|
2978
|
+
async runRecurring(backend, path, data, options) {
|
|
2979
|
+
if (options?.interval == null) throw new Error("runRecurring requires options.interval (ms)");
|
|
2980
|
+
if (!options?.assignedTo) throw new Error("runRecurring requires options.assignedTo");
|
|
2981
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
2982
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
2983
|
+
const rid = parseRecordIdString(recordId);
|
|
2984
|
+
const [existing] = await withRetry(this.logger, () => this.local.query("SELECT id FROM ONLY $id", { id: rid }));
|
|
2985
|
+
if (existing) return;
|
|
2986
|
+
record.recurring = true;
|
|
2987
|
+
record.interval = options.interval;
|
|
2988
|
+
record.next_run_at = /* @__PURE__ */ new Date();
|
|
2989
|
+
try {
|
|
2990
|
+
await this.create(recordId, record);
|
|
2991
|
+
} catch (err) {
|
|
2992
|
+
this.logger.debug({
|
|
2993
|
+
id: recordId,
|
|
2994
|
+
err: err?.message,
|
|
2995
|
+
Category: "sp00ky-client::DataModule::runRecurring"
|
|
2996
|
+
}, "runRecurring create skipped (schedule likely already exists)");
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
3001
|
+
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
3002
|
+
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
3003
|
+
* from that run's completion. No-op if no schedule exists (caller should have
|
|
3004
|
+
* created one via `runRecurring`).
|
|
3005
|
+
*/
|
|
3006
|
+
async pokeRecurring(backend, path, options) {
|
|
3007
|
+
if (!options?.assignedTo) throw new Error("pokeRecurring requires options.assignedTo");
|
|
3008
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
3009
|
+
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
3010
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
3011
|
+
const rid = parseRecordIdString(recordId);
|
|
3012
|
+
const [existing] = await withRetry(this.logger, () => this.local.query("SELECT id FROM ONLY $id", { id: rid }));
|
|
3013
|
+
if (!existing) return;
|
|
3014
|
+
await this.update(tableName, recordId, { next_run_at: /* @__PURE__ */ new Date() });
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
3018
|
+
* being dispatched server-side.
|
|
3019
|
+
*/
|
|
3020
|
+
async cancelRecurring(backend, path, options) {
|
|
3021
|
+
if (!options?.assignedTo) throw new Error("cancelRecurring requires options.assignedTo");
|
|
3022
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
3023
|
+
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
3024
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
3025
|
+
await this.delete(tableName, recordId);
|
|
2951
3026
|
}
|
|
2952
3027
|
/**
|
|
2953
3028
|
* Create a new record
|
|
@@ -4976,8 +5051,8 @@ function parseBackendInfo(raw) {
|
|
|
4976
5051
|
|
|
4977
5052
|
//#endregion
|
|
4978
5053
|
//#region src/modules/devtools/index.ts
|
|
4979
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
4980
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5054
|
+
const CORE_VERSION = "0.0.1-canary.113";
|
|
5055
|
+
const WASM_VERSION = "0.0.1-canary.113";
|
|
4981
5056
|
const SURREAL_VERSION = "3.0.3";
|
|
4982
5057
|
var DevToolsService = class {
|
|
4983
5058
|
eventsHistory = [];
|
|
@@ -7300,6 +7375,15 @@ var Sp00kyClient = class {
|
|
|
7300
7375
|
run(backend, path, payload, options) {
|
|
7301
7376
|
return this.dataModule.run(backend, path, payload, options);
|
|
7302
7377
|
}
|
|
7378
|
+
runRecurring(backend, path, payload, options) {
|
|
7379
|
+
return this.dataModule.runRecurring(backend, path, payload, options);
|
|
7380
|
+
}
|
|
7381
|
+
pokeRecurring(backend, path, options) {
|
|
7382
|
+
return this.dataModule.pokeRecurring(backend, path, options);
|
|
7383
|
+
}
|
|
7384
|
+
cancelRecurring(backend, path, options) {
|
|
7385
|
+
return this.dataModule.cancelRecurring(backend, path, options);
|
|
7386
|
+
}
|
|
7303
7387
|
bucket(name) {
|
|
7304
7388
|
return new BucketHandle(name, this.remote);
|
|
7305
7389
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -693,6 +693,12 @@ interface RunOptions {
|
|
|
693
693
|
* delayed the job stays pending (enqueued) and can still be killed.
|
|
694
694
|
*/
|
|
695
695
|
delay?: number;
|
|
696
|
+
/**
|
|
697
|
+
* Interval in milliseconds for a RECURRING job (see `runRecurring`). When set,
|
|
698
|
+
* the job re-runs `interval` ms after each run COMPLETES (drift-free from
|
|
699
|
+
* completion, not wall-clock). Ignored by the plain `run`.
|
|
700
|
+
*/
|
|
701
|
+
interval?: number;
|
|
696
702
|
}
|
|
697
703
|
/**
|
|
698
704
|
* Options for update operations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.113",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.113",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.113",
|
|
64
64
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
65
65
|
"@surrealdb/wasm": "^3.0.3",
|
|
66
66
|
"fast-json-patch": "^3.1.1",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { DataModule } from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the recurring-outbox API added to DataModule: runRecurring builds a
|
|
6
|
+
* single deterministic schedule row (idempotent, swallows a duplicate CREATE);
|
|
7
|
+
* pokeRecurring bumps next_run_at only when the schedule exists; cancelRecurring
|
|
8
|
+
* deletes it. The heavy create/update/delete pipeline is spied so these assert
|
|
9
|
+
* the orchestration (deterministic id, field-building, existence gating) without
|
|
10
|
+
* a real engine.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function makeLogger(): any {
|
|
14
|
+
const noop = () => {};
|
|
15
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
16
|
+
logger.child = () => logger;
|
|
17
|
+
return logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const schema = {
|
|
21
|
+
tables: [{ name: 'job', columns: {} }],
|
|
22
|
+
backends: {
|
|
23
|
+
gamesync: {
|
|
24
|
+
outboxTable: 'job',
|
|
25
|
+
routes: { '/syncGames': { args: { connection: { optional: false } } } },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CONN = 'connection:CONN_abc';
|
|
31
|
+
|
|
32
|
+
// localQueryResult drives the existence check ([] = absent, [row] = present).
|
|
33
|
+
function makeDm(localQueryResult: unknown[]) {
|
|
34
|
+
const local = { query: vi.fn().mockResolvedValue(localQueryResult) };
|
|
35
|
+
const dm = new DataModule({} as any, local as any, schema as any, makeLogger(), 100);
|
|
36
|
+
const create = vi.spyOn(dm, 'create').mockResolvedValue(undefined as any);
|
|
37
|
+
const update = vi.spyOn(dm, 'update').mockResolvedValue(undefined as any);
|
|
38
|
+
const del = vi.spyOn(dm, 'delete').mockResolvedValue(undefined as any);
|
|
39
|
+
return { dm, local, create, update, del };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('DataModule.runRecurring', () => {
|
|
43
|
+
it('creates a single deterministic schedule row with the recurring fields', async () => {
|
|
44
|
+
const { dm, create } = makeDm([]); // no existing row
|
|
45
|
+
await dm.runRecurring(
|
|
46
|
+
'gamesync' as any,
|
|
47
|
+
'/syncGames' as any,
|
|
48
|
+
{ connection: CONN } as any,
|
|
49
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
53
|
+
const [id, record] = create.mock.calls[0] as [string, any];
|
|
54
|
+
expect(id.startsWith('job:')).toBe(true);
|
|
55
|
+
expect(record.recurring).toBe(true);
|
|
56
|
+
expect(record.interval).toBe(300000);
|
|
57
|
+
expect(record.next_run_at).toBeInstanceOf(Date);
|
|
58
|
+
expect(record.assigned_to).toBe(CONN);
|
|
59
|
+
expect(record.path).toBe('/syncGames');
|
|
60
|
+
expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('is idempotent: does nothing when a schedule already exists', async () => {
|
|
64
|
+
const { dm, create } = makeDm([{ id: 'job:x' }]); // existing row
|
|
65
|
+
await dm.runRecurring(
|
|
66
|
+
'gamesync' as any,
|
|
67
|
+
'/syncGames' as any,
|
|
68
|
+
{ connection: CONN } as any,
|
|
69
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
70
|
+
);
|
|
71
|
+
expect(create).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('swallows a duplicate CREATE (row exists on server but not yet synced locally)', async () => {
|
|
75
|
+
const { dm, create } = makeDm([]); // local says absent
|
|
76
|
+
create.mockRejectedValueOnce(new Error('record already exists'));
|
|
77
|
+
await expect(
|
|
78
|
+
dm.runRecurring(
|
|
79
|
+
'gamesync' as any,
|
|
80
|
+
'/syncGames' as any,
|
|
81
|
+
{ connection: CONN } as any,
|
|
82
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
83
|
+
)
|
|
84
|
+
).resolves.toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('uses a stable id per (assignedTo, path)', async () => {
|
|
88
|
+
const a = makeDm([]);
|
|
89
|
+
await a.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
90
|
+
const b = makeDm([]);
|
|
91
|
+
await b.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
92
|
+
expect(a.create.mock.calls[0][0]).toBe(b.create.mock.calls[0][0]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('validates required options', async () => {
|
|
96
|
+
const { dm } = makeDm([]);
|
|
97
|
+
await expect(
|
|
98
|
+
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN } as any)
|
|
99
|
+
).rejects.toThrow(/interval/);
|
|
100
|
+
await expect(
|
|
101
|
+
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { interval: 300000 } as any)
|
|
102
|
+
).rejects.toThrow(/assignedTo/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('DataModule.pokeRecurring', () => {
|
|
107
|
+
it('bumps next_run_at on the existing schedule row', async () => {
|
|
108
|
+
const { dm, update } = makeDm([{ id: 'job:x' }]);
|
|
109
|
+
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
110
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
111
|
+
const [table, id, data] = update.mock.calls[0] as [string, string, any];
|
|
112
|
+
expect(table).toBe('job');
|
|
113
|
+
expect(id.startsWith('job:')).toBe(true);
|
|
114
|
+
expect(data.next_run_at).toBeInstanceOf(Date);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('is a no-op when no schedule exists', async () => {
|
|
118
|
+
const { dm, update } = makeDm([]);
|
|
119
|
+
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
120
|
+
expect(update).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('DataModule.cancelRecurring', () => {
|
|
125
|
+
it('deletes the deterministic schedule row', async () => {
|
|
126
|
+
const { dm, del, create } = makeDm([]);
|
|
127
|
+
// Capture the id runRecurring would create, to prove cancel targets the same.
|
|
128
|
+
await dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
129
|
+
const createdId = create.mock.calls[0][0];
|
|
130
|
+
|
|
131
|
+
await dm.cancelRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
132
|
+
expect(del).toHaveBeenCalledTimes(1);
|
|
133
|
+
const [table, id] = del.mock.calls[0] as [string, string];
|
|
134
|
+
expect(table).toBe('job');
|
|
135
|
+
expect(id).toBe(createdId);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1064,6 +1064,21 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1064
1064
|
data: RoutePayload<S, B, R>,
|
|
1065
1065
|
options?: RunOptions
|
|
1066
1066
|
): Promise<void> {
|
|
1067
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1068
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
1069
|
+
await this.create(recordId, record);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Build the outbox job record + resolve its table for a backend route. Shared
|
|
1074
|
+
* by `run` (one-shot) and `runRecurring` (durable schedule).
|
|
1075
|
+
*/
|
|
1076
|
+
private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1077
|
+
backend: B,
|
|
1078
|
+
path: R,
|
|
1079
|
+
data: RoutePayload<S, B, R>,
|
|
1080
|
+
options?: RunOptions
|
|
1081
|
+
): { tableName: string; record: Record<string, unknown> } {
|
|
1067
1082
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
1068
1083
|
if (!route) {
|
|
1069
1084
|
throw new Error(`Route ${backend}.${path} not found`);
|
|
@@ -1102,8 +1117,114 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1102
1117
|
record.assigned_to = options.assignedTo;
|
|
1103
1118
|
}
|
|
1104
1119
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1120
|
+
return { tableName, record };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Deterministic id for the single recurring-schedule row of a given
|
|
1125
|
+
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
1126
|
+
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
1127
|
+
*/
|
|
1128
|
+
private recurringJobId(tableName: string, assignedTo: string, path: string): string {
|
|
1129
|
+
const suffix = `${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1130
|
+
return `${tableName}:${suffix}`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
1135
|
+
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
1136
|
+
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
1137
|
+
* this on every connect/re-login never forks a second schedule. The first run
|
|
1138
|
+
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
1139
|
+
*/
|
|
1140
|
+
async runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1141
|
+
backend: B,
|
|
1142
|
+
path: R,
|
|
1143
|
+
data: RoutePayload<S, B, R>,
|
|
1144
|
+
options: RunOptions & { interval: number; assignedTo: string }
|
|
1145
|
+
): Promise<void> {
|
|
1146
|
+
if (options?.interval == null) {
|
|
1147
|
+
throw new Error('runRecurring requires options.interval (ms)');
|
|
1148
|
+
}
|
|
1149
|
+
if (!options?.assignedTo) {
|
|
1150
|
+
throw new Error('runRecurring requires options.assignedTo');
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1154
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
1155
|
+
const rid = parseRecordIdString(recordId);
|
|
1156
|
+
|
|
1157
|
+
// Single schedule per key: if the row already exists, do nothing.
|
|
1158
|
+
const [existing] = await withRetry(this.logger, () =>
|
|
1159
|
+
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1160
|
+
);
|
|
1161
|
+
if (existing) return;
|
|
1162
|
+
|
|
1163
|
+
record.recurring = true;
|
|
1164
|
+
record.interval = options.interval;
|
|
1165
|
+
record.next_run_at = new Date(); // run now, then re-arm to now + interval on completion
|
|
1166
|
+
try {
|
|
1167
|
+
await this.create(recordId, record);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
// The local existence check can miss a row that exists on the server but
|
|
1170
|
+
// hasn't synced into this session yet (e.g. a re-login before catch-up).
|
|
1171
|
+
// The deterministic id makes that CREATE collide; treat it as "schedule
|
|
1172
|
+
// already exists" and keep runRecurring idempotent rather than throwing.
|
|
1173
|
+
this.logger.debug(
|
|
1174
|
+
{ id: recordId, err: (err as Error)?.message, Category: 'sp00ky-client::DataModule::runRecurring' },
|
|
1175
|
+
'runRecurring create skipped (schedule likely already exists)'
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
1182
|
+
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
1183
|
+
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
1184
|
+
* from that run's completion. No-op if no schedule exists (caller should have
|
|
1185
|
+
* created one via `runRecurring`).
|
|
1186
|
+
*/
|
|
1187
|
+
async pokeRecurring<B extends BackendNames<S>>(
|
|
1188
|
+
backend: B,
|
|
1189
|
+
path: BackendRoutes<S, B>,
|
|
1190
|
+
options: { assignedTo: string }
|
|
1191
|
+
): Promise<void> {
|
|
1192
|
+
if (!options?.assignedTo) {
|
|
1193
|
+
throw new Error('pokeRecurring requires options.assignedTo');
|
|
1194
|
+
}
|
|
1195
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1196
|
+
if (!tableName) {
|
|
1197
|
+
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1198
|
+
}
|
|
1199
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1200
|
+
const rid = parseRecordIdString(recordId);
|
|
1201
|
+
|
|
1202
|
+
const [existing] = await withRetry(this.logger, () =>
|
|
1203
|
+
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1204
|
+
);
|
|
1205
|
+
if (!existing) return;
|
|
1206
|
+
|
|
1207
|
+
await this.update(tableName, recordId, { next_run_at: new Date() });
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
1212
|
+
* being dispatched server-side.
|
|
1213
|
+
*/
|
|
1214
|
+
async cancelRecurring<B extends BackendNames<S>>(
|
|
1215
|
+
backend: B,
|
|
1216
|
+
path: BackendRoutes<S, B>,
|
|
1217
|
+
options: { assignedTo: string }
|
|
1218
|
+
): Promise<void> {
|
|
1219
|
+
if (!options?.assignedTo) {
|
|
1220
|
+
throw new Error('cancelRecurring requires options.assignedTo');
|
|
1221
|
+
}
|
|
1222
|
+
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1223
|
+
if (!tableName) {
|
|
1224
|
+
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1225
|
+
}
|
|
1226
|
+
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1227
|
+
await this.delete(tableName, recordId);
|
|
1107
1228
|
}
|
|
1108
1229
|
|
|
1109
1230
|
// ==================== MUTATION MANAGEMENT ====================
|
package/src/sp00ky.ts
CHANGED
|
@@ -754,6 +754,34 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
754
754
|
return this.dataModule.run(backend, path, payload, options);
|
|
755
755
|
}
|
|
756
756
|
|
|
757
|
+
runRecurring<
|
|
758
|
+
B extends BackendNames<S>,
|
|
759
|
+
R extends BackendRoutes<S, B>,
|
|
760
|
+
>(
|
|
761
|
+
backend: B,
|
|
762
|
+
path: R,
|
|
763
|
+
payload: RoutePayload<S, B, R>,
|
|
764
|
+
options: RunOptions & { interval: number; assignedTo: string }
|
|
765
|
+
) {
|
|
766
|
+
return this.dataModule.runRecurring(backend, path, payload, options);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
pokeRecurring<B extends BackendNames<S>>(
|
|
770
|
+
backend: B,
|
|
771
|
+
path: BackendRoutes<S, B>,
|
|
772
|
+
options: { assignedTo: string }
|
|
773
|
+
) {
|
|
774
|
+
return this.dataModule.pokeRecurring(backend, path, options);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
cancelRecurring<B extends BackendNames<S>>(
|
|
778
|
+
backend: B,
|
|
779
|
+
path: BackendRoutes<S, B>,
|
|
780
|
+
options: { assignedTo: string }
|
|
781
|
+
) {
|
|
782
|
+
return this.dataModule.cancelRecurring(backend, path, options);
|
|
783
|
+
}
|
|
784
|
+
|
|
757
785
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
758
786
|
return new BucketHandle(name, this.remote);
|
|
759
787
|
}
|
package/src/types.ts
CHANGED
|
@@ -425,6 +425,12 @@ export interface RunOptions {
|
|
|
425
425
|
* delayed the job stays pending (enqueued) and can still be killed.
|
|
426
426
|
*/
|
|
427
427
|
delay?: number;
|
|
428
|
+
/**
|
|
429
|
+
* Interval in milliseconds for a RECURRING job (see `runRecurring`). When set,
|
|
430
|
+
* the job re-runs `interval` ms after each run COMPLETES (drift-free from
|
|
431
|
+
* completion, not wall-clock). Ignored by the plain `run`.
|
|
432
|
+
*/
|
|
433
|
+
interval?: number;
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
/**
|