@tribe-nest/forge 1.20.1 → 1.20.2
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/package.json +1 -1
- package/src/server/jobs.ts +76 -25
package/package.json
CHANGED
package/src/server/jobs.ts
CHANGED
|
@@ -49,6 +49,19 @@ export interface AppJobsStatus {
|
|
|
49
49
|
queued: Array<{ name: string; runAt: number; attempts: number }>;
|
|
50
50
|
/** One-time jobs that exhausted their retries and were given up on, newest first. */
|
|
51
51
|
deadLetters: Array<{ name: string; attempts: number; failedAt: number; error: string | null }>;
|
|
52
|
+
/**
|
|
53
|
+
* Recent runs, newest first — every job, not only the last one. A run showing
|
|
54
|
+
* `status: "running"` with an old `startedAt` means your app never answered and
|
|
55
|
+
* may STILL be working; `durationMs` stays null until a run completes.
|
|
56
|
+
*/
|
|
57
|
+
recentRuns: Array<{
|
|
58
|
+
name: string;
|
|
59
|
+
script: string | null;
|
|
60
|
+
startedAt: number;
|
|
61
|
+
durationMs: number | null;
|
|
62
|
+
status: string;
|
|
63
|
+
error: string | null;
|
|
64
|
+
}>;
|
|
52
65
|
lastDispatchAt: number | null;
|
|
53
66
|
/** Which job the last delivery attempt was for — all jobs share one error slot. */
|
|
54
67
|
lastDispatchJob: string | null;
|
|
@@ -133,8 +146,9 @@ export async function enqueueAppJob(
|
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
149
|
+
* Why a job did or didn't run — read `recentRuns` (every run, with duration + outcome) and `deadLetters` (jobs that gave up), NOT `queueDepth`, which reads 0 whether a job succeeded or was never enqueued.
|
|
150
|
+
*
|
|
151
|
+
* The only window into a fire-and-forget runtime with no logs. Call it from a server route
|
|
138
152
|
* (server-only, needs the JOBS binding) and gate it behind `verifyAppAdmin`:
|
|
139
153
|
*
|
|
140
154
|
* export const Route = createFileRoute("/api/tn-jobs/status")({
|
|
@@ -192,46 +206,83 @@ export async function handleAppJobRun(
|
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
209
|
+
* Write app-owned rows to a collection — pass `{ upsertKey: 'yourIdField' }` so re-runs UPDATE instead of duplicating (without it every call inserts; `replace: true` archives everything first and is only for a complete dataset).
|
|
210
|
+
*
|
|
211
|
+
* The sanctioned server-side write path — do NOT invent an endpoint.
|
|
212
|
+
*
|
|
213
|
+
* PICK ONE OF THREE MODES:
|
|
214
|
+
*
|
|
215
|
+
* 1. `upsertKey` — INCREMENTAL, and what you almost always want. Rows are matched
|
|
216
|
+
* on the field(s) you name and updated in place; anything new is inserted.
|
|
217
|
+
* Nothing else is touched, so you can sync just what changed:
|
|
198
218
|
*
|
|
199
|
-
*
|
|
200
|
-
* NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…), so re-running WILL
|
|
201
|
-
* duplicate rows. For idempotency use `replace: true` (archives every existing
|
|
202
|
-
* app-owned row in the collection, then inserts) AND re-pull the FULL dataset each
|
|
203
|
-
* run. NEVER combine `replace: true` with an incremental window (e.g.
|
|
204
|
-
* updated_at_min / last-7-days) — it archives the rows outside the window too and
|
|
205
|
-
* you lose history. (True incremental upsert-by-key is not supported yet.)
|
|
219
|
+
* await writeAppCollection(ctx, cfg, "orders", changed, { upsertKey: "orderId" })
|
|
206
220
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
221
|
+
* Use several fields when one isn't unique: `{ upsertKey: ["shop", "orderId"] }`.
|
|
222
|
+
* A row missing any key field is inserted rather than matched, so an incomplete
|
|
223
|
+
* record can never collide with another.
|
|
209
224
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
225
|
+
* 2. `replace: true` — archives EVERY existing row, then inserts what you sent.
|
|
226
|
+
* Only correct when the payload is the COMPLETE dataset. Combining it with a
|
|
227
|
+
* time window ("last 7 days") archives everything outside that window and
|
|
228
|
+
* destroys your history. It also grows with your data: a full replace of a
|
|
229
|
+
* large collection is a big write and will eventually time out. Prefer
|
|
230
|
+
* `upsertKey`.
|
|
231
|
+
*
|
|
232
|
+
* 3. Neither — plain insert. Every call adds rows, so re-running duplicates them.
|
|
233
|
+
* Fine for append-only logs, wrong for a sync.
|
|
234
|
+
*
|
|
235
|
+
* A stable title does NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…);
|
|
236
|
+
* only `upsertKey` does.
|
|
237
|
+
*
|
|
238
|
+
* Write at most 1000 rows per call — page larger syncs.
|
|
218
239
|
*/
|
|
219
240
|
export async function writeAppCollection(
|
|
220
241
|
ctx: JobContext,
|
|
221
242
|
cfg: AppJobsConfig,
|
|
222
243
|
slug: string,
|
|
223
244
|
entries: Array<{ data: Record<string, unknown>; status?: "draft" | "published" }>,
|
|
224
|
-
opts?: {
|
|
225
|
-
|
|
245
|
+
opts?: {
|
|
246
|
+
/** Archive every existing row first. Only for a COMPLETE dataset — see above. */
|
|
247
|
+
replace?: boolean;
|
|
248
|
+
/** Field(s) identifying a row, so existing rows are updated instead of duplicated. */
|
|
249
|
+
upsertKey?: string | string[];
|
|
250
|
+
},
|
|
251
|
+
): Promise<{ created: number; updated: number; archived: number }> {
|
|
226
252
|
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/entries`, {
|
|
227
253
|
method: "POST",
|
|
228
254
|
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
229
|
-
body: JSON.stringify({ entries, replace: opts?.replace === true }),
|
|
255
|
+
body: JSON.stringify({ entries, replace: opts?.replace === true, upsertKey: opts?.upsertKey }),
|
|
230
256
|
});
|
|
231
257
|
if (!res.ok) throw new Error(`writeAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
232
258
|
return res.json();
|
|
233
259
|
}
|
|
234
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Delete app-owned rows by key — use this instead of `replace: true` when you only need to drop some records (rows are archived, not destroyed).
|
|
263
|
+
*
|
|
264
|
+
* await deleteAppCollection(ctx, cfg, "orders", "orderId", ["1001", "1002"])
|
|
265
|
+
*
|
|
266
|
+
* Rows are archived, not destroyed, so a mistaken sync can be recovered. Use this
|
|
267
|
+
* instead of `replace: true` when you only need to drop a few records.
|
|
268
|
+
*/
|
|
269
|
+
export async function deleteAppCollection(
|
|
270
|
+
ctx: JobContext,
|
|
271
|
+
cfg: AppJobsConfig,
|
|
272
|
+
slug: string,
|
|
273
|
+
upsertKey: string | string[],
|
|
274
|
+
keys: string[],
|
|
275
|
+
): Promise<{ deleted: number }> {
|
|
276
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/entries`, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
279
|
+
body: JSON.stringify({ upsertKey, deleteKeys: keys }),
|
|
280
|
+
});
|
|
281
|
+
if (!res.ok) throw new Error(`deleteAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
282
|
+
return res.json();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
235
286
|
/**
|
|
236
287
|
* Read an app's OWN collection back from a JOB handler (the read counterpart to
|
|
237
288
|
* writeAppCollection), authenticated by the run's app-sync token. Reads at `app`
|