@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 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. Shared
588
- * by `run` (one-shot) and `runRecurring` (durable schedule).
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. Shared
2993
- * by `run` (one-shot) and `runRecurring` (durable schedule).
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.147";
5116
- const WASM_VERSION = "0.0.1-canary.147";
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.147",
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.147",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.147",
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. Shared
1178
- * by `run` (one-shot) and `runRecurring` (durable schedule).
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
- });