@spooky-sync/core 0.0.1-canary.147 → 0.0.1-canary.149
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 +5 -46
- package/dist/index.js +7 -77
- package/dist/types.d.ts +0 -6
- package/package.json +3 -3
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/index.ts +5 -109
- package/src/sp00ky.ts +0 -28
- package/src/types.ts +0 -6
- package/src/modules/data/data.recurring.test.ts +0 -153
package/dist/index.d.ts
CHANGED
|
@@ -584,44 +584,13 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
584
584
|
notifyQuerySynced(queryHash: string): Promise<void>;
|
|
585
585
|
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, data: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
|
|
586
586
|
/**
|
|
587
|
-
* Build the outbox job record + resolve its table for a backend route.
|
|
588
|
-
*
|
|
587
|
+
* Build the outbox job record + resolve its table for a backend route.
|
|
588
|
+
*
|
|
589
|
+
* Every job is a single execution. Recurring work is declared server-side
|
|
590
|
+
* (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
|
|
591
|
+
* so nothing here needs to know about schedules.
|
|
589
592
|
*/
|
|
590
593
|
private buildJobRecord;
|
|
591
|
-
/**
|
|
592
|
-
* Deterministic id for the single recurring-schedule row of a given
|
|
593
|
-
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
594
|
-
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
595
|
-
*/
|
|
596
|
-
private recurringJobId;
|
|
597
|
-
/**
|
|
598
|
-
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
599
|
-
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
600
|
-
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
601
|
-
* this on every connect/re-login never forks a second schedule. The first run
|
|
602
|
-
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
603
|
-
*/
|
|
604
|
-
runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, data: RoutePayload<S, B, R>, options: RunOptions & {
|
|
605
|
-
interval: number;
|
|
606
|
-
assignedTo: string;
|
|
607
|
-
}): Promise<void>;
|
|
608
|
-
/**
|
|
609
|
-
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
610
|
-
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
611
|
-
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
612
|
-
* from that run's completion. No-op if no schedule exists (caller should have
|
|
613
|
-
* created one via `runRecurring`).
|
|
614
|
-
*/
|
|
615
|
-
pokeRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
616
|
-
assignedTo: string;
|
|
617
|
-
}): Promise<void>;
|
|
618
|
-
/**
|
|
619
|
-
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
620
|
-
* being dispatched server-side.
|
|
621
|
-
*/
|
|
622
|
-
cancelRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
623
|
-
assignedTo: string;
|
|
624
|
-
}): Promise<void>;
|
|
625
594
|
/**
|
|
626
595
|
* Create a new record
|
|
627
596
|
*/
|
|
@@ -1427,16 +1396,6 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1427
1396
|
*/
|
|
1428
1397
|
reportFrontendTiming(queryHash: string, ms: number): void;
|
|
1429
1398
|
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
|
|
1430
|
-
runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options: RunOptions & {
|
|
1431
|
-
interval: number;
|
|
1432
|
-
assignedTo: string;
|
|
1433
|
-
}): Promise<void>;
|
|
1434
|
-
pokeRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
1435
|
-
assignedTo: string;
|
|
1436
|
-
}): Promise<void>;
|
|
1437
|
-
cancelRecurring<B extends BackendNames<S>>(backend: B, path: BackendRoutes<S, B>, options: {
|
|
1438
|
-
assignedTo: string;
|
|
1439
|
-
}): Promise<void>;
|
|
1440
1399
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle;
|
|
1441
1400
|
create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
1442
1401
|
update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
|
package/dist/index.js
CHANGED
|
@@ -2989,8 +2989,11 @@ var DataModule = class {
|
|
|
2989
2989
|
await this.create(recordId, record);
|
|
2990
2990
|
}
|
|
2991
2991
|
/**
|
|
2992
|
-
* Build the outbox job record + resolve its table for a backend route.
|
|
2993
|
-
*
|
|
2992
|
+
* Build the outbox job record + resolve its table for a backend route.
|
|
2993
|
+
*
|
|
2994
|
+
* Every job is a single execution. Recurring work is declared server-side
|
|
2995
|
+
* (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
|
|
2996
|
+
* so nothing here needs to know about schedules.
|
|
2994
2997
|
*/
|
|
2995
2998
|
buildJobRecord(backend, path, data, options) {
|
|
2996
2999
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
@@ -3019,70 +3022,6 @@ var DataModule = class {
|
|
|
3019
3022
|
};
|
|
3020
3023
|
}
|
|
3021
3024
|
/**
|
|
3022
|
-
* Deterministic id for the single recurring-schedule row of a given
|
|
3023
|
-
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
3024
|
-
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
3025
|
-
*/
|
|
3026
|
-
recurringJobId(tableName, assignedTo, path) {
|
|
3027
|
-
return `${tableName}:${`${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
|
|
3028
|
-
}
|
|
3029
|
-
/**
|
|
3030
|
-
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
3031
|
-
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
3032
|
-
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
3033
|
-
* this on every connect/re-login never forks a second schedule. The first run
|
|
3034
|
-
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
3035
|
-
*/
|
|
3036
|
-
async runRecurring(backend, path, data, options) {
|
|
3037
|
-
if (options?.interval == null) throw new Error("runRecurring requires options.interval (ms)");
|
|
3038
|
-
if (!options?.assignedTo) throw new Error("runRecurring requires options.assignedTo");
|
|
3039
|
-
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
3040
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
3041
|
-
const rid = parseRecordIdString(recordId);
|
|
3042
|
-
const [existing] = await withRetry(this.logger, () => this.local.query("SELECT id FROM ONLY $id", { id: rid }));
|
|
3043
|
-
if (existing) return;
|
|
3044
|
-
record.recurring = true;
|
|
3045
|
-
record.interval = options.interval;
|
|
3046
|
-
record.next_run_at = /* @__PURE__ */ new Date();
|
|
3047
|
-
try {
|
|
3048
|
-
await this.create(recordId, record);
|
|
3049
|
-
} catch (err) {
|
|
3050
|
-
this.logger.debug({
|
|
3051
|
-
id: recordId,
|
|
3052
|
-
err: err?.message,
|
|
3053
|
-
Category: "sp00ky-client::DataModule::runRecurring"
|
|
3054
|
-
}, "runRecurring create skipped (schedule likely already exists)");
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
/**
|
|
3058
|
-
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
3059
|
-
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
3060
|
-
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
3061
|
-
* from that run's completion. No-op if no schedule exists (caller should have
|
|
3062
|
-
* created one via `runRecurring`).
|
|
3063
|
-
*/
|
|
3064
|
-
async pokeRecurring(backend, path, options) {
|
|
3065
|
-
if (!options?.assignedTo) throw new Error("pokeRecurring requires options.assignedTo");
|
|
3066
|
-
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
3067
|
-
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
3068
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
3069
|
-
const rid = parseRecordIdString(recordId);
|
|
3070
|
-
const [existing] = await withRetry(this.logger, () => this.local.query("SELECT id FROM ONLY $id", { id: rid }));
|
|
3071
|
-
if (!existing) return;
|
|
3072
|
-
await this.update(tableName, recordId, { next_run_at: /* @__PURE__ */ new Date() });
|
|
3073
|
-
}
|
|
3074
|
-
/**
|
|
3075
|
-
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
3076
|
-
* being dispatched server-side.
|
|
3077
|
-
*/
|
|
3078
|
-
async cancelRecurring(backend, path, options) {
|
|
3079
|
-
if (!options?.assignedTo) throw new Error("cancelRecurring requires options.assignedTo");
|
|
3080
|
-
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
3081
|
-
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
3082
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
3083
|
-
await this.delete(tableName, recordId);
|
|
3084
|
-
}
|
|
3085
|
-
/**
|
|
3086
3025
|
* Create a new record
|
|
3087
3026
|
*/
|
|
3088
3027
|
async create(id, data) {
|
|
@@ -5112,8 +5051,8 @@ function parseBackendInfo(raw) {
|
|
|
5112
5051
|
|
|
5113
5052
|
//#endregion
|
|
5114
5053
|
//#region src/modules/devtools/index.ts
|
|
5115
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5116
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5054
|
+
const CORE_VERSION = "0.0.1-canary.149";
|
|
5055
|
+
const WASM_VERSION = "0.0.1-canary.149";
|
|
5117
5056
|
const SURREAL_VERSION = "3.0.3";
|
|
5118
5057
|
var DevToolsService = class {
|
|
5119
5058
|
eventsHistory = [];
|
|
@@ -7734,15 +7673,6 @@ var Sp00kyClient = class {
|
|
|
7734
7673
|
run(backend, path, payload, options) {
|
|
7735
7674
|
return this.dataModule.run(backend, path, payload, options);
|
|
7736
7675
|
}
|
|
7737
|
-
runRecurring(backend, path, payload, options) {
|
|
7738
|
-
return this.dataModule.runRecurring(backend, path, payload, options);
|
|
7739
|
-
}
|
|
7740
|
-
pokeRecurring(backend, path, options) {
|
|
7741
|
-
return this.dataModule.pokeRecurring(backend, path, options);
|
|
7742
|
-
}
|
|
7743
|
-
cancelRecurring(backend, path, options) {
|
|
7744
|
-
return this.dataModule.cancelRecurring(backend, path, options);
|
|
7745
|
-
}
|
|
7746
7676
|
bucket(name) {
|
|
7747
7677
|
return new BucketHandle(name, this.remote);
|
|
7748
7678
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -727,12 +727,6 @@ interface RunOptions {
|
|
|
727
727
|
* delayed the job stays pending (enqueued) and can still be killed.
|
|
728
728
|
*/
|
|
729
729
|
delay?: number;
|
|
730
|
-
/**
|
|
731
|
-
* Interval in milliseconds for a RECURRING job (see `runRecurring`). When set,
|
|
732
|
-
* the job re-runs `interval` ms after each run COMPLETES (drift-free from
|
|
733
|
-
* completion, not wall-clock). Ignored by the plain `run`.
|
|
734
|
-
*/
|
|
735
|
-
interval?: number;
|
|
736
730
|
}
|
|
737
731
|
/**
|
|
738
732
|
* 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.149",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.149",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.149",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"fast-json-patch": "^3.1.1",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { DataModule } from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for `DataModule.run`, the one-shot outbox API.
|
|
6
|
+
*
|
|
7
|
+
* Recurring jobs used to live here too (`runRecurring` / `pokeRecurring` /
|
|
8
|
+
* `cancelRecurring`, which wrote a single durable row the runner re-armed
|
|
9
|
+
* forever). They are gone: schedules are now declared server-side under
|
|
10
|
+
* `schedules:` in sp00ky.yml, and the scheduler creates a fresh job row per
|
|
11
|
+
* cycle — so every row this API writes is exactly one execution.
|
|
12
|
+
*
|
|
13
|
+
* The create/update pipeline is spied, so these assert the orchestration
|
|
14
|
+
* (table resolution, argument validation, field building) without a real engine.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function makeLogger(): any {
|
|
18
|
+
const noop = () => {};
|
|
19
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
20
|
+
logger.child = () => logger;
|
|
21
|
+
return logger;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const schema = {
|
|
25
|
+
tables: [{ name: 'job', columns: {} }],
|
|
26
|
+
backends: {
|
|
27
|
+
gamesync: {
|
|
28
|
+
outboxTable: 'job',
|
|
29
|
+
routes: {
|
|
30
|
+
'/syncGames': {
|
|
31
|
+
args: { connection: { optional: false }, since: { optional: true } },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
noOutbox: {
|
|
36
|
+
routes: { '/whatever': { args: {} } },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CONN = 'connection:CONN_abc';
|
|
42
|
+
|
|
43
|
+
function makeDm() {
|
|
44
|
+
const local = { query: vi.fn().mockResolvedValue([]) };
|
|
45
|
+
const dm = new DataModule({} as any, local as any, schema as any, makeLogger(), 100);
|
|
46
|
+
const create = vi.spyOn(dm, 'create').mockResolvedValue(undefined as any);
|
|
47
|
+
return { dm, local, create };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('DataModule.run', () => {
|
|
51
|
+
it('creates the one-shot job with status pending so the optimistic row reads in-flight', async () => {
|
|
52
|
+
const { dm, create } = makeDm();
|
|
53
|
+
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, {
|
|
54
|
+
assignedTo: CONN,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
58
|
+
const [id, record] = create.mock.calls[0] as [string, any];
|
|
59
|
+
expect(id.startsWith('job:')).toBe(true);
|
|
60
|
+
// The schema's DEFAULT ALWAYS "pending" only runs server-side; without the
|
|
61
|
+
// explicit field the local optimistic row has status undefined and
|
|
62
|
+
// in-flight indicators miss it until the first server echo.
|
|
63
|
+
expect(record.status).toBe('pending');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('gives each call its own row', async () => {
|
|
67
|
+
const { dm, create } = makeDm();
|
|
68
|
+
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
|
|
69
|
+
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
|
|
70
|
+
|
|
71
|
+
const [firstId] = create.mock.calls[0] as [string, any];
|
|
72
|
+
const [secondId] = create.mock.calls[1] as [string, any];
|
|
73
|
+
expect(firstId).not.toBe(secondId);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('carries the retry, timeout and delay options onto the row', async () => {
|
|
77
|
+
const { dm, create } = makeDm();
|
|
78
|
+
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, {
|
|
79
|
+
assignedTo: CONN,
|
|
80
|
+
max_retries: 5,
|
|
81
|
+
retry_strategy: 'exponential',
|
|
82
|
+
timeout: 30,
|
|
83
|
+
delay: 60_000,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const [, record] = create.mock.calls[0] as [string, any];
|
|
87
|
+
expect(record.max_retries).toBe(5);
|
|
88
|
+
expect(record.retry_strategy).toBe('exponential');
|
|
89
|
+
expect(record.timeout).toBe(30);
|
|
90
|
+
expect(record.delay).toBe(60_000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('serializes the payload and rejects a missing required argument', async () => {
|
|
94
|
+
const { dm, create } = makeDm();
|
|
95
|
+
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
|
|
96
|
+
const [, record] = create.mock.calls[0] as [string, any];
|
|
97
|
+
expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
dm.run('gamesync' as any, '/syncGames' as any, {} as any)
|
|
101
|
+
).rejects.toThrow(/connection/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects an unknown route and a backend with no outbox table', async () => {
|
|
105
|
+
const { dm } = makeDm();
|
|
106
|
+
await expect(
|
|
107
|
+
dm.run('gamesync' as any, '/nope' as any, {} as any)
|
|
108
|
+
).rejects.toThrow(/not found/);
|
|
109
|
+
await expect(
|
|
110
|
+
dm.run('noOutbox' as any, '/whatever' as any, {} as any)
|
|
111
|
+
).rejects.toThrow(/Outbox table/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1174,8 +1174,11 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1174
1174
|
}
|
|
1175
1175
|
|
|
1176
1176
|
/**
|
|
1177
|
-
* Build the outbox job record + resolve its table for a backend route.
|
|
1178
|
-
*
|
|
1177
|
+
* Build the outbox job record + resolve its table for a backend route.
|
|
1178
|
+
*
|
|
1179
|
+
* Every job is a single execution. Recurring work is declared server-side
|
|
1180
|
+
* (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
|
|
1181
|
+
* so nothing here needs to know about schedules.
|
|
1179
1182
|
*/
|
|
1180
1183
|
private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1181
1184
|
backend: B,
|
|
@@ -1229,113 +1232,6 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1229
1232
|
return { tableName, record };
|
|
1230
1233
|
}
|
|
1231
1234
|
|
|
1232
|
-
/**
|
|
1233
|
-
* Deterministic id for the single recurring-schedule row of a given
|
|
1234
|
-
* (assigned_to, path). One row per pair => calling `runRecurring` twice cannot
|
|
1235
|
-
* fork a second schedule, and `poke`/`cancel` address the same row.
|
|
1236
|
-
*/
|
|
1237
|
-
private recurringJobId(tableName: string, assignedTo: string, path: string): string {
|
|
1238
|
-
const suffix = `${assignedTo}_${path}`.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1239
|
-
return `${tableName}:${suffix}`;
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
/**
|
|
1243
|
-
* Register a RECURRING job: one durable row per (assigned_to, path) that
|
|
1244
|
-
* re-runs every `options.interval` ms (measured from each run's completion).
|
|
1245
|
-
* Idempotent: if the schedule already exists it is left untouched, so calling
|
|
1246
|
-
* this on every connect/re-login never forks a second schedule. The first run
|
|
1247
|
-
* fires immediately (`next_run_at = now`), then every interval thereafter.
|
|
1248
|
-
*/
|
|
1249
|
-
async runRecurring<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1250
|
-
backend: B,
|
|
1251
|
-
path: R,
|
|
1252
|
-
data: RoutePayload<S, B, R>,
|
|
1253
|
-
options: RunOptions & { interval: number; assignedTo: string }
|
|
1254
|
-
): Promise<void> {
|
|
1255
|
-
if (options?.interval == null) {
|
|
1256
|
-
throw new Error('runRecurring requires options.interval (ms)');
|
|
1257
|
-
}
|
|
1258
|
-
if (!options?.assignedTo) {
|
|
1259
|
-
throw new Error('runRecurring requires options.assignedTo');
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1263
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path);
|
|
1264
|
-
const rid = parseRecordIdString(recordId);
|
|
1265
|
-
|
|
1266
|
-
// Single schedule per key: if the row already exists, do nothing.
|
|
1267
|
-
const [existing] = await withRetry(this.logger, () =>
|
|
1268
|
-
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1269
|
-
);
|
|
1270
|
-
if (existing) return;
|
|
1271
|
-
|
|
1272
|
-
record.recurring = true;
|
|
1273
|
-
record.interval = options.interval;
|
|
1274
|
-
record.next_run_at = new Date(); // run now, then re-arm to now + interval on completion
|
|
1275
|
-
try {
|
|
1276
|
-
await this.create(recordId, record);
|
|
1277
|
-
} catch (err) {
|
|
1278
|
-
// The local existence check can miss a row that exists on the server but
|
|
1279
|
-
// hasn't synced into this session yet (e.g. a re-login before catch-up).
|
|
1280
|
-
// The deterministic id makes that CREATE collide; treat it as "schedule
|
|
1281
|
-
// already exists" and keep runRecurring idempotent rather than throwing.
|
|
1282
|
-
this.logger.debug(
|
|
1283
|
-
{ id: recordId, err: (err as Error)?.message, Category: 'sp00ky-client::DataModule::runRecurring' },
|
|
1284
|
-
'runRecurring create skipped (schedule likely already exists)'
|
|
1285
|
-
);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* Manually trigger a recurring job NOW and reset its interval clock. Sets
|
|
1291
|
-
* `next_run_at = now` on the schedule row; the SSP ingest picks up the update
|
|
1292
|
-
* and dispatches an immediate run, after which the runner re-arms the clock
|
|
1293
|
-
* from that run's completion. No-op if no schedule exists (caller should have
|
|
1294
|
-
* created one via `runRecurring`).
|
|
1295
|
-
*/
|
|
1296
|
-
async pokeRecurring<B extends BackendNames<S>>(
|
|
1297
|
-
backend: B,
|
|
1298
|
-
path: BackendRoutes<S, B>,
|
|
1299
|
-
options: { assignedTo: string }
|
|
1300
|
-
): Promise<void> {
|
|
1301
|
-
if (!options?.assignedTo) {
|
|
1302
|
-
throw new Error('pokeRecurring requires options.assignedTo');
|
|
1303
|
-
}
|
|
1304
|
-
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1305
|
-
if (!tableName) {
|
|
1306
|
-
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1307
|
-
}
|
|
1308
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1309
|
-
const rid = parseRecordIdString(recordId);
|
|
1310
|
-
|
|
1311
|
-
const [existing] = await withRetry(this.logger, () =>
|
|
1312
|
-
this.local.query<[unknown]>('SELECT id FROM ONLY $id', { id: rid })
|
|
1313
|
-
);
|
|
1314
|
-
if (!existing) return;
|
|
1315
|
-
|
|
1316
|
-
await this.update(tableName, recordId, { next_run_at: new Date() });
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* Cancel a recurring schedule: delete the single schedule row so it stops
|
|
1321
|
-
* being dispatched server-side.
|
|
1322
|
-
*/
|
|
1323
|
-
async cancelRecurring<B extends BackendNames<S>>(
|
|
1324
|
-
backend: B,
|
|
1325
|
-
path: BackendRoutes<S, B>,
|
|
1326
|
-
options: { assignedTo: string }
|
|
1327
|
-
): Promise<void> {
|
|
1328
|
-
if (!options?.assignedTo) {
|
|
1329
|
-
throw new Error('cancelRecurring requires options.assignedTo');
|
|
1330
|
-
}
|
|
1331
|
-
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
1332
|
-
if (!tableName) {
|
|
1333
|
-
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
1334
|
-
}
|
|
1335
|
-
const recordId = this.recurringJobId(tableName, options.assignedTo, path as string);
|
|
1336
|
-
await this.delete(tableName, recordId);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
1235
|
// ==================== MUTATION MANAGEMENT ====================
|
|
1340
1236
|
|
|
1341
1237
|
/**
|
package/src/sp00ky.ts
CHANGED
|
@@ -959,34 +959,6 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
959
959
|
return this.dataModule.run(backend, path, payload, options);
|
|
960
960
|
}
|
|
961
961
|
|
|
962
|
-
runRecurring<
|
|
963
|
-
B extends BackendNames<S>,
|
|
964
|
-
R extends BackendRoutes<S, B>,
|
|
965
|
-
>(
|
|
966
|
-
backend: B,
|
|
967
|
-
path: R,
|
|
968
|
-
payload: RoutePayload<S, B, R>,
|
|
969
|
-
options: RunOptions & { interval: number; assignedTo: string }
|
|
970
|
-
) {
|
|
971
|
-
return this.dataModule.runRecurring(backend, path, payload, options);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
pokeRecurring<B extends BackendNames<S>>(
|
|
975
|
-
backend: B,
|
|
976
|
-
path: BackendRoutes<S, B>,
|
|
977
|
-
options: { assignedTo: string }
|
|
978
|
-
) {
|
|
979
|
-
return this.dataModule.pokeRecurring(backend, path, options);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
cancelRecurring<B extends BackendNames<S>>(
|
|
983
|
-
backend: B,
|
|
984
|
-
path: BackendRoutes<S, B>,
|
|
985
|
-
options: { assignedTo: string }
|
|
986
|
-
) {
|
|
987
|
-
return this.dataModule.cancelRecurring(backend, path, options);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
962
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
991
963
|
return new BucketHandle(name, this.remote);
|
|
992
964
|
}
|
package/src/types.ts
CHANGED
|
@@ -461,12 +461,6 @@ export interface RunOptions {
|
|
|
461
461
|
* delayed the job stays pending (enqueued) and can still be killed.
|
|
462
462
|
*/
|
|
463
463
|
delay?: number;
|
|
464
|
-
/**
|
|
465
|
-
* Interval in milliseconds for a RECURRING job (see `runRecurring`). When set,
|
|
466
|
-
* the job re-runs `interval` ms after each run COMPLETES (drift-free from
|
|
467
|
-
* completion, not wall-clock). Ignored by the plain `run`.
|
|
468
|
-
*/
|
|
469
|
-
interval?: number;
|
|
470
464
|
}
|
|
471
465
|
|
|
472
466
|
/**
|
|
@@ -1,153 +0,0 @@
|
|
|
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(record.status).toBe('pending');
|
|
61
|
-
expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('is idempotent: does nothing when a schedule already exists', async () => {
|
|
65
|
-
const { dm, create } = makeDm([{ id: 'job:x' }]); // existing row
|
|
66
|
-
await dm.runRecurring(
|
|
67
|
-
'gamesync' as any,
|
|
68
|
-
'/syncGames' as any,
|
|
69
|
-
{ connection: CONN } as any,
|
|
70
|
-
{ assignedTo: CONN, interval: 300000 }
|
|
71
|
-
);
|
|
72
|
-
expect(create).not.toHaveBeenCalled();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('swallows a duplicate CREATE (row exists on server but not yet synced locally)', async () => {
|
|
76
|
-
const { dm, create } = makeDm([]); // local says absent
|
|
77
|
-
create.mockRejectedValueOnce(new Error('record already exists'));
|
|
78
|
-
await expect(
|
|
79
|
-
dm.runRecurring(
|
|
80
|
-
'gamesync' as any,
|
|
81
|
-
'/syncGames' as any,
|
|
82
|
-
{ connection: CONN } as any,
|
|
83
|
-
{ assignedTo: CONN, interval: 300000 }
|
|
84
|
-
)
|
|
85
|
-
).resolves.toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('uses a stable id per (assignedTo, path)', async () => {
|
|
89
|
-
const a = makeDm([]);
|
|
90
|
-
await a.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
91
|
-
const b = makeDm([]);
|
|
92
|
-
await b.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
93
|
-
expect(a.create.mock.calls[0][0]).toBe(b.create.mock.calls[0][0]);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('validates required options', async () => {
|
|
97
|
-
const { dm } = makeDm([]);
|
|
98
|
-
await expect(
|
|
99
|
-
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN } as any)
|
|
100
|
-
).rejects.toThrow(/interval/);
|
|
101
|
-
await expect(
|
|
102
|
-
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { interval: 300000 } as any)
|
|
103
|
-
).rejects.toThrow(/assignedTo/);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('DataModule.run', () => {
|
|
108
|
-
it('creates the one-shot job with status pending so the optimistic row reads in-flight', async () => {
|
|
109
|
-
const { dm, create } = makeDm([]);
|
|
110
|
-
await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN });
|
|
111
|
-
|
|
112
|
-
expect(create).toHaveBeenCalledTimes(1);
|
|
113
|
-
const [id, record] = create.mock.calls[0] as [string, any];
|
|
114
|
-
expect(id.startsWith('job:')).toBe(true);
|
|
115
|
-
// The schema's DEFAULT ALWAYS "pending" only runs server-side; without the
|
|
116
|
-
// explicit field the local optimistic row has status undefined and
|
|
117
|
-
// in-flight indicators miss it until the first server echo.
|
|
118
|
-
expect(record.status).toBe('pending');
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('DataModule.pokeRecurring', () => {
|
|
123
|
-
it('bumps next_run_at on the existing schedule row', async () => {
|
|
124
|
-
const { dm, update } = makeDm([{ id: 'job:x' }]);
|
|
125
|
-
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
126
|
-
expect(update).toHaveBeenCalledTimes(1);
|
|
127
|
-
const [table, id, data] = update.mock.calls[0] as [string, string, any];
|
|
128
|
-
expect(table).toBe('job');
|
|
129
|
-
expect(id.startsWith('job:')).toBe(true);
|
|
130
|
-
expect(data.next_run_at).toBeInstanceOf(Date);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('is a no-op when no schedule exists', async () => {
|
|
134
|
-
const { dm, update } = makeDm([]);
|
|
135
|
-
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
136
|
-
expect(update).not.toHaveBeenCalled();
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe('DataModule.cancelRecurring', () => {
|
|
141
|
-
it('deletes the deterministic schedule row', async () => {
|
|
142
|
-
const { dm, del, create } = makeDm([]);
|
|
143
|
-
// Capture the id runRecurring would create, to prove cancel targets the same.
|
|
144
|
-
await dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
145
|
-
const createdId = create.mock.calls[0][0];
|
|
146
|
-
|
|
147
|
-
await dm.cancelRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
148
|
-
expect(del).toHaveBeenCalledTimes(1);
|
|
149
|
-
const [table, id] = del.mock.calls[0] as [string, string];
|
|
150
|
-
expect(table).toBe('job');
|
|
151
|
-
expect(id).toBe(createdId);
|
|
152
|
-
});
|
|
153
|
-
});
|