@tribe-nest/forge 1.13.0 → 1.15.0
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 +4 -2
- package/src/server/jobs.ts +70 -7
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"publishConfig": {
|
|
3
|
+
"version": "1.15.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
5
7
|
"description": "Forge — the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
|
|
6
8
|
"exports": {
|
|
7
9
|
".": "./src/index.ts",
|
package/src/server/jobs.ts
CHANGED
|
@@ -21,11 +21,27 @@ interface AppJobsStub {
|
|
|
21
21
|
export interface JobSchedule {
|
|
22
22
|
/** Stable job name — also the key in your job map + the `scheduleId` in the token. */
|
|
23
23
|
name: string;
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Fixed interval in milliseconds (e.g. 60_000 = every minute). Provide this OR
|
|
26
|
+
* `cron`, not both. Best for "every N minutes/hours"; the first run lands one
|
|
27
|
+
* interval after the deploy.
|
|
28
|
+
*/
|
|
29
|
+
intervalMs?: number;
|
|
30
|
+
// A 5-field UTC cron expression: minute hour day-of-month month day-of-week.
|
|
31
|
+
// e.g. "0 3 * * *" = daily at 03:00 UTC; "0 0,6,12,18 * * *" = every 6 hours;
|
|
32
|
+
// "0 9 * * 1-5" = 09:00 UTC on weekdays. Provide this OR `intervalMs`. Use
|
|
33
|
+
// cron when a run must land at a specific wall-clock time; evaluation is UTC.
|
|
34
|
+
cron?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
/** Context passed to a job handler — carries the platform token so the handler
|
|
38
|
+
* can write app-owned data back (writeAppCollection). */
|
|
39
|
+
export interface JobContext {
|
|
40
|
+
/** The app-sync token this run was invoked with (forward it to writes). */
|
|
41
|
+
token: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type JobHandler = (payload: unknown, env: unknown, ctx: JobContext) => Promise<void> | void;
|
|
29
45
|
|
|
30
46
|
export interface AppJobsConfig {
|
|
31
47
|
/** The app id (VITE_APP_ID). */
|
|
@@ -41,7 +57,11 @@ const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.J
|
|
|
41
57
|
// A stable fingerprint of the desired schedule set, so the DO skips reconcile
|
|
42
58
|
// when nothing changed (sorted by name → order-independent).
|
|
43
59
|
function scheduleHash(schedules: JobSchedule[]): string {
|
|
44
|
-
return JSON.stringify(
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
[...schedules]
|
|
62
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
63
|
+
.map((s) => [s.name, s.intervalMs ?? null, s.cron ?? null]),
|
|
64
|
+
);
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
/**
|
|
@@ -66,9 +86,9 @@ export async function enqueueAppJob(
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
69
|
-
* Handle the DO's alarm callback (`POST /
|
|
89
|
+
* Handle the DO's alarm callback (`POST /api/tn-jobs/run`). Verifies the platform
|
|
70
90
|
* token, looks up the named handler in your job map, and runs it with the app's
|
|
71
|
-
* `env` (so `env.SHOPIFY_TOKEN` etc. are available). Wire your `/
|
|
91
|
+
* `env` (so `env.SHOPIFY_TOKEN` etc. are available). Wire your `/api/tn-jobs/run` route
|
|
72
92
|
* to this.
|
|
73
93
|
*/
|
|
74
94
|
export async function handleAppJobRun(
|
|
@@ -95,10 +115,53 @@ export async function handleAppJobRun(
|
|
|
95
115
|
const handler = body.job ? jobs[body.job] : undefined;
|
|
96
116
|
if (!handler) return new Response("unknown job", { status: 404 });
|
|
97
117
|
try {
|
|
98
|
-
|
|
118
|
+
// Pass the verified token so the handler can write app-owned data back
|
|
119
|
+
// (writeAppCollection) without re-minting anything.
|
|
120
|
+
await handler(body.payload ?? null, env, { token });
|
|
99
121
|
return new Response("ok");
|
|
100
122
|
} catch (err) {
|
|
101
123
|
// Non-2xx → the DO retries (one-time) / re-fires next interval (recurring).
|
|
102
124
|
return new Response(`job failed: ${(err as Error).message}`, { status: 500 });
|
|
103
125
|
}
|
|
104
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persist APP-OWNED rows into one of the app's collections from a job handler —
|
|
130
|
+
* the sanctioned server-side write path (do NOT invent an endpoint). Authenticated
|
|
131
|
+
* by the run's token (from `JobContext`).
|
|
132
|
+
*
|
|
133
|
+
* IMPORTANT — this ALWAYS INSERTS (there is no upsert-by-key). A stable title does
|
|
134
|
+
* NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…), so re-running WILL
|
|
135
|
+
* duplicate rows. For idempotency use `replace: true` (archives every existing
|
|
136
|
+
* app-owned row in the collection, then inserts) AND re-pull the FULL dataset each
|
|
137
|
+
* run. NEVER combine `replace: true` with an incremental window (e.g.
|
|
138
|
+
* updated_at_min / last-7-days) — it archives the rows outside the window too and
|
|
139
|
+
* you lose history. (True incremental upsert-by-key is not supported yet.)
|
|
140
|
+
*
|
|
141
|
+
* Handlers receive `(payload, env, ctx)` — pass `ctx` here; `cfg` is your own
|
|
142
|
+
* config built from `import.meta.env`.
|
|
143
|
+
*
|
|
144
|
+
* jobs = {
|
|
145
|
+
* "sync-shopify": async (_p, env, ctx) => {
|
|
146
|
+
* const orders = await fetchShopify(env);
|
|
147
|
+
* await writeAppCollection(ctx, cfg, "shopify-orders",
|
|
148
|
+
* orders.map(o => ({ data: { orderId: o.id, total: o.total, utmSource: o.utm } })),
|
|
149
|
+
* { replace: true });
|
|
150
|
+
* },
|
|
151
|
+
* };
|
|
152
|
+
*/
|
|
153
|
+
export async function writeAppCollection(
|
|
154
|
+
ctx: JobContext,
|
|
155
|
+
cfg: AppJobsConfig,
|
|
156
|
+
slug: string,
|
|
157
|
+
entries: Array<{ data: Record<string, unknown>; status?: "draft" | "published" }>,
|
|
158
|
+
opts?: { replace?: boolean },
|
|
159
|
+
): Promise<{ created: number }> {
|
|
160
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/entries`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
163
|
+
body: JSON.stringify({ entries, replace: opts?.replace === true }),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) throw new Error(`writeAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
166
|
+
return res.json();
|
|
167
|
+
}
|