@assembly-js/app-foundation 0.0.37 → 0.0.39

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/CLAUDE.md CHANGED
@@ -1197,7 +1197,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1197
1197
  - ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
1198
1198
  - ❌ Don't `throw new Error` with a sentence the user should read from a server action. Production redacts that throw, so the toast shows a generic React error and the response is HTTP 500. Return `{ ok: false, message }` and toast `message`. Let `TokenInvalidError` and unexpected failures throw. See `assembly-action-results`.
1199
1199
  - ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Use `sendThrottled` from `@assembly-js/app-foundation/server`. A fixed handful of calls is the only exception. See "Write rate limit" above.
1200
- - ❌ Don't re-notify a still-true condition from a cron. Record the alert on the subject before the send and clear it when the condition ends. See "Recurring conditions" in `assembly-notifications`.
1200
+ - ❌ Don't re-notify a still-true condition from a cron. Mark a recipient sent only after `createNotification` succeeds, and clear that record when the condition ends unless the alert is once per billing period; keep those successful outcomes until the period resets, even if usage dips below the threshold. See "Recurring conditions" in `assembly-notifications`.
1201
1201
  - ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
1202
1202
  - ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
1203
1203
  - ❌ Don't write a scalar string to a custom field that has options. **All option-bearing custom fields are `multiSelect` in Assembly's schema** — even ones that LOOK like single-select dropdowns. The value must always be an array of option keys: `{ favorite_pizza: ['pepperoni'] }`, never `{ favorite_pizza: 'pepperoni' }`. A scalar string for a multiSelect causes a 400 with `invalid custom field option <value> provided`.
@@ -13,7 +13,9 @@
13
13
  // cronWiringChangedThisTurn). Everything else — a template clone, a Vercel
14
14
  // build, an update turn that never touched the cron — gets the same findings
15
15
  // as warnings: those paths have no agent who can act on a failure, so failing
16
- // them would only strand an app that already worked.
16
+ // them would only strand an app that already worked. A below-floor schedule
17
+ // the app shipped before the turn stays a warning even then (see
18
+ // baselineCronSchedules).
17
19
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
18
20
  import { spawnSync } from 'node:child_process';
19
21
  import { join, relative, sep } from 'node:path';
@@ -65,6 +67,30 @@ function isCronWiringPath(file) {
65
67
  return CRON_WIRING_PATHS.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p));
66
68
  }
67
69
 
70
+ // The cron entries vercel.json carried at the turn baseline, keyed by path and
71
+ // schedule. A schedule the app already shipped is product behavior the turn has
72
+ // no mandate to change, so its interval finding stays a warning even when the
73
+ // turn rewired the route behind it. An unreadable baseline yields an empty set,
74
+ // which keeps every interval finding fatal.
75
+ function baselineCronSchedules() {
76
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
77
+ const schedules = new Set();
78
+ if (!baseline) return schedules;
79
+ const result = spawnSync('git', ['show', `${baseline}:vercel.json`], { cwd: ROOT, encoding: 'utf8' });
80
+ if (result.status !== 0) return schedules;
81
+ try {
82
+ const crons = JSON.parse(result.stdout).crons;
83
+ if (Array.isArray(crons)) for (const c of crons) schedules.add(cronKey(c));
84
+ } catch {
85
+ // An unparseable baseline file shipped no schedule this turn could preserve.
86
+ }
87
+ return schedules;
88
+ }
89
+
90
+ function cronKey(entry) {
91
+ return `${entry?.path}\n${typeof entry?.schedule === 'string' ? entry.schedule.trim() : ''}`;
92
+ }
93
+
68
94
  // Whether this turn changed vercel.json or a cron route: commits since the
69
95
  // baseline plus whatever is still uncommitted. No baseline means no turn (a
70
96
  // Vercel build, a clone bootstrap), so nothing is the agent's to fix. A git
@@ -226,6 +252,8 @@ if (crons !== undefined && !Array.isArray(crons)) {
226
252
  }
227
253
  const entries = Array.isArray(crons) ? crons : [];
228
254
  const coveredRoutes = new Set();
255
+ const shippedSchedules = baselineCronSchedules();
256
+ const preexistingIntervals = [];
229
257
 
230
258
  entries.forEach((entry, i) => {
231
259
  const label = `vercel.json crons[${i}]`;
@@ -251,10 +279,12 @@ entries.forEach((entry, i) => {
251
279
  if (interval === null) {
252
280
  problems.push(`${label} (${entry.path}): "${entry.schedule}" is not a valid 5-field cron expression.`);
253
281
  } else if (interval < MIN_INTERVAL_MINUTES) {
254
- problems.push(
255
- `${label} (${entry.path}): "${entry.schedule}" fires every ${interval} minutes; the floor is every ` +
256
- `${MIN_INTERVAL_MINUTES}. Use "*/30 * * * *" and keep per-action cadences in the app's schedule table.`,
257
- );
282
+ const finding = `${label} (${entry.path}): "${entry.schedule}" fires every ${interval} minutes; the floor is every ${MIN_INTERVAL_MINUTES}.`;
283
+ if (shippedSchedules.has(cronKey(entry))) {
284
+ preexistingIntervals.push(`${finding} The app already shipped this schedule, so leave it unchanged.`);
285
+ } else {
286
+ problems.push(`${finding} Use "*/30 * * * *" and keep per-action cadences in the app's schedule table.`);
287
+ }
258
288
  }
259
289
  });
260
290
 
@@ -281,4 +311,5 @@ if (entries.length > 0) {
281
311
  }
282
312
  }
283
313
 
314
+ if (preexistingIntervals.length > 0) report(preexistingIntervals, { fatal: false });
284
315
  if (problems.length > 0) report(problems, { fatal: cronWiringChangedThisTurn() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -10,7 +10,9 @@ description: Use when the app must notify internal users or clients, or needs pe
10
10
 
11
11
  ## Mental model
12
12
 
13
- Send a notification when something happens that the recipient should know about and didn't cause themselves — action required (review, approve, respond) or awareness (status changes, completions, new messages). Skip routine reads, navigation, and trivial state changes. Under-send rather than over-send.
13
+ Send a notification when something happens that the recipient should know about — action required (review, approve, respond) or awareness (status changes, completions, new messages). Skip routine reads, navigation, trivial state changes, and routine confirmations of the recipient's own actions.
14
+
15
+ For a required condition alert (a retainer reaching 90%, going over its allowance, or a budget crossing its limit), the audience is everyone specified by the requirement who has access, including the user whose action crossed the threshold. A sole admin logging usage must still receive the staff alert. Choose recipients by access; use the actor as sender without automatically removing them from the audience.
14
16
 
15
17
  Every notification is three decisions: **audience** (internal users and/or clients), **channel** (in-product, email, or both), and **context on arrival** (what the recipient sees and can do). Get those right; the rest is mechanics.
16
18
 
@@ -338,17 +340,11 @@ Option 2 is cheaper (no list call) and more reliable. Pick option 1 when you don
338
340
 
339
341
  A cron that scans for a condition still being true — unassigned for a day, overdue, no reply — selects the same rows on every tick. `createNotification` does not remember a previous send, so each tick creates another copy for every recipient. `sendThrottled` only spaces that flood out; it does not stop it.
340
342
 
341
- Record the alert on the subject, in the app's own table, before sending. Key it by the subject and the condition (one thread, one kind), not by the recipient. Skip the send when that marker is set. Clear the marker when the condition ends (the thread is assigned, a reply arrives) so a later recurrence can alert again.
343
+ Track each condition occurrence in the app's own table, keyed by subject and condition (include the billing period for a recurring allowance). Resolve the eligible audience before claiming the occurrence: an empty audience creates no sent marker.
342
344
 
343
- ```ts
344
- // Unique on (threadId, kind). Write the marker first so a tick that dies
345
- // mid-send skips the rest next time instead of notifying everyone again.
346
- if (await alreadyAlerted(thread.id, 'stale-unassigned')) continue;
347
- await markAlerted(thread.id, 'stale-unassigned');
348
- await sendThrottled(recipients, (u) =>
349
- assembly.createNotification({ requestBody: { recipientInternalUserId: u.id, /* ... */ } }),
350
- );
351
- ```
345
+ A concurrency claim reserves work; it is not proof of delivery. Keep pending claims retryable after a failure or an expired lease, and mark a recipient sent only after their `createNotification` succeeds. On partial failure, retry the failed recipients and skip those already sent. If there is only one recipient, track that outcome on the occurrence itself. Clear the occurrence when the condition ends if recurrence should alert again. For once-per-period threshold alerts, keep successful outcomes through dips below the threshold and use a new occurrence when the period resets.
346
+
347
+ A permanent marker written before fan-out can consume an alert without sending anything. Marking the whole occurrence sent after `sendThrottled` is also insufficient: it returns per-recipient outcomes, so check failures before declaring delivery complete.
352
348
 
353
349
  Do not use `listNotifications` to decide. A sweep will not page the whole center, and a notification the recipient already read still has to count.
354
350
 
@@ -461,7 +457,7 @@ Each client gets their own badge and clears it independently. `recipientCompanyI
461
457
  ## What NOT to do
462
458
 
463
459
  - Do not `Promise.all` a recipient-list fan-out. Writes cap at 5 concurrent; firing a whole list at once 429s and drops notifications. Use `sendThrottled` (see "Throttling fan-outs"). A fixed handful of calls is the only exception.
464
- - Do not re-send a cron alert because the same condition is still true. Record it on the subject before the send and clear that record when the condition ends (see "Recurring conditions").
460
+ - Do not re-send a condition alert to recipients already notified for that occurrence. Keep failed sends retryable and track successful outcomes (see "Recurring conditions").
465
461
  - Do not use `recipientId` — deprecated. Use `recipientClientId` or `recipientInternalUserId`.
466
462
  - Do not pass `recipientCompanyId` alone as a recipient. The API rejects it. To notify a company, fan out across its clients (see "Notifying a company").
467
463
  - Do not omit the company id when a multi-company client is involved. Notifying a multi-company client needs `recipientCompanyId` (`400 "company ID is required when notifying a client"`); a multi-company client *sending* to an internal user needs `senderCompanyId` (`400 "sender company ID is invalid based on sender"`). Single-company members are auto-inferred. See "Company scoping, by recipient type."
@@ -144,7 +144,7 @@ Rules that fall out of this:
144
144
 
145
145
  - **`cronRoute` is not optional.** The route is a public URL; without the wrapper anyone who guesses it can trigger the app's scheduled work under the app's own credentials. Never hand-roll the header check.
146
146
  - **Use `getOfflineAssembly()`** for every platform call — there is no viewer and no cookie. `getSessionIdentity()` is `null` here, so a notification's `senderId` must be chosen explicitly (a system/admin internal user); see `assembly-notifications`.
147
- - **A scan that notifies must remember what it already sent.** Selecting every row that still matches (unassigned, overdue, unanswered) and calling `createNotification` notifies that same set again on the next tick. Record the alert on the subject before the send and clear it when the condition ends. See `assembly-notifications` ("Recurring conditions").
147
+ - **A scan that notifies must remember what it already sent.** Selecting every row that still matches (unassigned, overdue, unanswered) and calling `createNotification` notifies that same set again on the next tick. Mark a recipient sent only after `createNotification` succeeds, and clear that record when the condition ends unless the alert is once per billing period; keep those successful outcomes until the period resets, even if usage dips below the threshold. See `assembly-notifications` ("Recurring conditions").
148
148
  - **A missed tick is harmless.** Vercel does not retry, but the row stays due until it is marked, so the next tick picks it up. Never mark a row done before its work succeeded.
149
149
  - **A stuck row frees itself.** If Vercel stops the function mid-run, the catch never runs and the row stays `running`; the stale-claim clause lets the next tick reclaim it after 15 minutes. Keep `claimedAt` and that clause — without them one hang parks a row forever.
150
150
  - **Never compute the next run by adding minutes to `now`.** Use `nextRunAfter`: it anchors to the scheduled time, skips missed occurrences, and keeps daily cadences on their local wall-clock time across daylight-saving changes.