@tribe-nest/forge 1.16.0 → 1.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "1.16.0",
3
+ "version": "1.19.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,6 +10,7 @@ import type {
10
10
  import { useForge } from "../../provider/ForgeProvider";
11
11
  import { useQuery, useMutation } from "@tanstack/react-query";
12
12
  import { collectionParamsToQuery, collectionParamsKey, splitCollectionQuery, collectionQueryKey } from "../collectionParams";
13
+ import { APP_ACCESS_TOKEN_KEY } from "../../contexts/AppAuthContext";
13
14
 
14
15
  // Generic data hooks for CMS collections. Collection-agnostic — a lawyers
15
16
  // directory and a recipe index use the same three hooks. The bespoke rendering
@@ -119,6 +120,63 @@ export function useCollectionAggregate(
119
120
  });
120
121
  }
121
122
 
123
+ // Prefer the app-user token (useAppAuth) when the viewer is an app user;
124
+ // otherwise fall back to the Forge client's own token (an app-admin's TribeNest
125
+ // session). Both are accepted by the app read endpoint. Returns a per-request
126
+ // Axios config that overrides Authorization, or undefined to use the default.
127
+ function appAuthConfig(): { headers: Record<string, string> } | undefined {
128
+ const t = typeof localStorage !== "undefined" ? localStorage.getItem(APP_ACCESS_TOKEN_KEY) : null;
129
+ return t ? { headers: { Authorization: `Bearer ${t}` } } : undefined;
130
+ }
131
+
132
+ /**
133
+ * Read an APP's OWN collection at `app` scope from the browser (a dashboard) —
134
+ * ALL rows, including `private`/unpublished ones. Authenticated as the logged-in
135
+ * app user (useAppAuth) or app admin. Use THIS (not useCollectionQuery) for
136
+ * app-owned/business data so the collection can stay `visibility: 'private'` and
137
+ * never be world-readable via the profile's public API.
138
+ */
139
+ export function useAppCollectionQuery(
140
+ slug?: string,
141
+ query?: CollectionQuery,
142
+ options?: { enabled?: boolean; initialData?: CollectionSearchResult },
143
+ ) {
144
+ const { client, appId } = useForge();
145
+ const { query: body } = splitCollectionQuery(query);
146
+
147
+ return useQuery<CollectionSearchResult>({
148
+ queryKey: ["app-collection-query", appId, slug, collectionQueryKey(query)],
149
+ queryFn: async () => {
150
+ const res = await client.post(`/public/apps/${appId}/collections/${slug}/query`, { query: body }, appAuthConfig());
151
+ return res.data;
152
+ },
153
+ enabled: (options?.enabled ?? true) && !!appId && !!client && !!slug,
154
+ initialData: options?.initialData,
155
+ });
156
+ }
157
+
158
+ /** Aggregate an APP's OWN collection from the browser (see useAppCollectionQuery). */
159
+ export function useAppCollectionAggregate(
160
+ slug?: string,
161
+ aggregate?: CollectionAggregate,
162
+ opts?: { where?: CollectionQuery["where"]; search?: string; enabled?: boolean },
163
+ ) {
164
+ const { client, appId } = useForge();
165
+
166
+ return useQuery<CollectionAggregateBucket[]>({
167
+ queryKey: ["app-collection-aggregate", appId, slug, JSON.stringify({ aggregate, ...opts })],
168
+ queryFn: async () => {
169
+ const res = await client.post(
170
+ `/public/apps/${appId}/collections/${slug}/aggregate`,
171
+ { where: opts?.where, search: opts?.search, aggregate },
172
+ appAuthConfig(),
173
+ );
174
+ return res.data.data;
175
+ },
176
+ enabled: (opts?.enabled ?? true) && !!appId && !!client && !!slug && !!aggregate,
177
+ });
178
+ }
179
+
122
180
  export type SubmitCollectionEntryInput = {
123
181
  data: Record<string, unknown>;
124
182
  /** For anonymous submissions, when the collection requires them. */
@@ -14,8 +14,49 @@ export interface AppJobsEnv {
14
14
  }
15
15
 
16
16
  interface AppJobsStub {
17
- reconcile: (script: string, appId: string, desired: JobSchedule[], hash: string) => Promise<{ changed: boolean }>;
18
- enqueue: (script: string, appId: string, name: string, payload: unknown, delayMs: number) => Promise<void>;
17
+ reconcile: (
18
+ script: string,
19
+ appId: string,
20
+ desired: JobSchedule[],
21
+ hash: string,
22
+ host: string,
23
+ ) => Promise<{ changed: boolean }>;
24
+ enqueue: (
25
+ script: string,
26
+ appId: string,
27
+ name: string,
28
+ payload: unknown,
29
+ delayMs: number,
30
+ host: string,
31
+ ) => Promise<void>;
32
+ status: () => Promise<AppJobsStatus>;
33
+ }
34
+
35
+ /**
36
+ * A snapshot of the app's job runtime — what's scheduled, how deep the queue is,
37
+ * and what happened on the LAST delivery attempt. Because jobs fail silently (no
38
+ * logs / dead-letter), this is how you SEE why one didn't run:
39
+ * - `lastDispatchAt` null while `alarmAt` is in the past → the alarm isn't firing;
40
+ * - `appHost` null → this app predates host registration; redeploy it;
41
+ * - `lastError` starting "dispatch threw:" → the callback never reached your app
42
+ * (host unroutable / your Worker is down);
43
+ * - `lastDispatchStatus` "401" → your app was reached but rejected the token
44
+ * (the dispatch-worker's APP_SYNC_SECRET ≠ the backend JWT_SECRET);
45
+ * - `lastDispatchStatus` "402" → the site is suspended for billing;
46
+ * - `lastDispatchStatus` "404" → no /api/tn-jobs/run route (or an unknown job name).
47
+ */
48
+ export interface AppJobsStatus {
49
+ appId: string | null;
50
+ /** The public host job runs are posted to. Null on apps deployed before this existed. */
51
+ appHost: string | null;
52
+ now: number;
53
+ alarmAt: number | null;
54
+ schedules: Array<{ name: string; intervalMs: number | null; cron: string | null; nextRun: number }>;
55
+ queueDepth: number;
56
+ lastDispatchAt: number | null;
57
+ lastDispatchScript: string | null;
58
+ lastDispatchStatus: string | null;
59
+ lastError: string | null;
19
60
  }
20
61
 
21
62
  export interface JobSchedule {
@@ -50,6 +91,14 @@ export interface AppJobsConfig {
50
91
  script: string;
51
92
  /** The TribeNest public API base (VITE_API_URL) — used to verify the callback token. */
52
93
  apiUrl: string;
94
+ /**
95
+ * This app's public host, no scheme (VITE_APP_HOST) — e.g. "acme.tribenest.co".
96
+ * The job runtime posts each run to `https://<host>/api/tn-jobs/run`; it cannot
97
+ * reach the app any other way, so an app that never registers a host will not
98
+ * run jobs (its status reports "no app host recorded"). Injected at deploy —
99
+ * do not hand-set it.
100
+ */
101
+ host: string;
53
102
  }
54
103
 
55
104
  const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.JOBS.idFromName(appId));
@@ -71,7 +120,7 @@ function scheduleHash(schedules: JobSchedule[]): string {
71
120
  * one, a removed one is pruned.
72
121
  */
73
122
  export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, schedules: JobSchedule[]): Promise<void> {
74
- await stub(env, cfg.appId).reconcile(cfg.script, cfg.appId, schedules, scheduleHash(schedules));
123
+ await stub(env, cfg.appId).reconcile(cfg.script, cfg.appId, schedules, scheduleHash(schedules), cfg.host);
75
124
  }
76
125
 
77
126
  /** Enqueue a durable one-time job (survives redeploys, retried on failure). */
@@ -82,7 +131,26 @@ export async function enqueueAppJob(
82
131
  payload?: unknown,
83
132
  delayMs = 0,
84
133
  ): Promise<void> {
85
- await stub(env, cfg.appId).enqueue(cfg.script, cfg.appId, name, payload ?? null, delayMs);
134
+ await stub(env, cfg.appId).enqueue(cfg.script, cfg.appId, name, payload ?? null, delayMs, cfg.host);
135
+ }
136
+
137
+ /**
138
+ * Inspect the app's job runtime — the ONLY window into why a job did or didn't
139
+ * run (jobs are fire-and-forget with no logs). Call it from a server route
140
+ * (server-only, needs the JOBS binding) and gate it behind `verifyAppAdmin`:
141
+ *
142
+ * export const Route = createFileRoute("/api/tn-jobs/status")({
143
+ * server: { handlers: { GET: async ({ request }) => {
144
+ * if (!(await verifyAppAdmin(request, jobsConfig))) return new Response("forbidden", { status: 403 });
145
+ * const { env } = await import("cloudflare:workers");
146
+ * return Response.json(await appJobsStatus(env, jobsConfig));
147
+ * } } },
148
+ * });
149
+ *
150
+ * See {@link AppJobsStatus} for how to read the result.
151
+ */
152
+ export async function appJobsStatus(env: AppJobsEnv, cfg: AppJobsConfig): Promise<AppJobsStatus> {
153
+ return stub(env, cfg.appId).status();
86
154
  }
87
155
 
88
156
  /**
@@ -165,3 +233,43 @@ export async function writeAppCollection(
165
233
  if (!res.ok) throw new Error(`writeAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
166
234
  return res.json();
167
235
  }
236
+
237
+ /**
238
+ * Read an app's OWN collection back from a JOB handler (the read counterpart to
239
+ * writeAppCollection), authenticated by the run's app-sync token. Reads at `app`
240
+ * scope — ALL rows, including `private`/unpublished ones — so the collection can
241
+ * stay `visibility: 'private'` (never world-readable) yet the app still reads it.
242
+ * `query` is the query-engine DSL ({ where?, sort?, select?, page?, limit?, … }).
243
+ * For reading app-owned data in the browser (a dashboard) use the client hook
244
+ * `useAppCollectionQuery` instead — this one needs the job token.
245
+ */
246
+ export async function queryAppCollection(
247
+ ctx: JobContext,
248
+ cfg: AppJobsConfig,
249
+ slug: string,
250
+ query?: Record<string, unknown>,
251
+ ): Promise<{ data: unknown[]; total: number; [k: string]: unknown }> {
252
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/query`, {
253
+ method: "POST",
254
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
255
+ body: JSON.stringify({ query: query ?? {} }),
256
+ });
257
+ if (!res.ok) throw new Error(`queryAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
258
+ return res.json();
259
+ }
260
+
261
+ /** Aggregate over an app's OWN collection from a job handler (see queryAppCollection). */
262
+ export async function aggregateAppCollection(
263
+ ctx: JobContext,
264
+ cfg: AppJobsConfig,
265
+ slug: string,
266
+ input: { aggregate: unknown; where?: unknown; search?: string },
267
+ ): Promise<unknown> {
268
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/aggregate`, {
269
+ method: "POST",
270
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
271
+ body: JSON.stringify(input),
272
+ });
273
+ if (!res.ok) throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
274
+ return res.json();
275
+ }