@spooky-sync/core 0.0.1-canary.111 → 0.0.1-canary.112

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
@@ -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
- const recordId = `${tableName}:${generateId()}`;
2950
- await this.create(recordId, record);
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.111";
4980
- const WASM_VERSION = "0.0.1-canary.111";
5054
+ const CORE_VERSION = "0.0.1-canary.112";
5055
+ const WASM_VERSION = "0.0.1-canary.112";
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.111",
3
+ "version": "0.0.1-canary.112",
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.111",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.111",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.112",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.112",
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
- const recordId = `${tableName}:${generateId()}`;
1106
- await this.create(recordId, record);
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
  /**