@tribe-nest/forge 1.20.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "1.20.0",
3
+ "version": "1.20.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -171,7 +171,12 @@ export function useAppCollectionAggregate(
171
171
  { where: opts?.where, search: opts?.search, aggregate },
172
172
  appAuthConfig(),
173
173
  );
174
- return res.data.data;
174
+ // NOT `res.data.data`. The /public/apps/* endpoints return the result
175
+ // BARE — only the profile-scoped /public/collections/* ones wrap it in
176
+ // `{ data }`. Unwrapping here resolved undefined, which React Query
177
+ // rejects outright, so every call errored before reaching the caller.
178
+ // Matches useAppCollectionQuery above; keep the two in step.
179
+ return res.data;
175
180
  },
176
181
  enabled: (opts?.enabled ?? true) && !!appId && !!client && !!slug && !!aggregate,
177
182
  });
@@ -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
- * Inspect the app's job runtime the ONLY window into why a job did or didn't
137
- * run (jobs are fire-and-forget with no logs). Call it from a server route
149
+ * Why a job did or didn't runread `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
- * Persist APP-OWNED rows into one of the app's collections from a job handler —
196
- * the sanctioned server-side write path (do NOT invent an endpoint). Authenticated
197
- * by the run's token (from `JobContext`).
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
- * IMPORTANT this ALWAYS INSERTS (there is no upsert-by-key). A stable title does
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
- * Handlers receive `(payload, env, ctx)` pass `ctx` here; `cfg` is your own
208
- * config built from `import.meta.env`.
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
- * jobs = {
211
- * "sync-shopify": async (_p, env, ctx) => {
212
- * const orders = await fetchShopify(env);
213
- * await writeAppCollection(ctx, cfg, "shopify-orders",
214
- * orders.map(o => ({ data: { orderId: o.id, total: o.total, utmSource: o.utm } })),
215
- * { replace: true });
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?: { replace?: boolean },
225
- ): Promise<{ created: number }> {
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`