@assembly-js/app-foundation 0.0.34 → 0.0.36
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 +10 -6
- package/bin/validate-manifest.mjs +8 -4
- package/dist/replace-rows.d.ts +27 -0
- package/dist/replace-rows.js +51 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +4 -2
- package/skills/assembly-notifications/SKILL.md +19 -0
- package/skills/assembly-record-writes/SKILL.md +50 -15
- package/skills/assembly-scheduled-work/SKILL.md +2 -0
package/CLAUDE.md
CHANGED
|
@@ -160,7 +160,9 @@ Before adding a feature, check whether Assembly already covers it. If it does, *
|
|
|
160
160
|
**Then ask who should perform it — you or the platform.** Reaching a module through the SDK is right when the user is doing the thing *now*: they pressed a button and are watching it happen. When the effect should instead happen *in reaction to* something that happens in the app ("when a project is completed, message the client"), that is a workflow that integrates with the Assembly platform, and **the platform performs it, not your code.** The test is where the effect lands: on Assembly's own data rather than only in your app's tables. Decide this **before** you write or plan the call — read the `assembly-automations` skill to decide it, and don't reach for the module's SDK method until you have.
|
|
161
161
|
<!-- /feature:api-resources -->
|
|
162
162
|
|
|
163
|
-
**
|
|
163
|
+
**Always available (every workspace):** Clients, companies, and custom fields — see "The data model" below. Use them; do not rebuild them. Notification infrastructure (`listNotifications`, `createNotification`, `deleteNotification`, `markNotificationRead`, `markNotificationUnread`) is also always available.
|
|
164
|
+
|
|
165
|
+
**Optional modules (may or may not be installed in this workspace):** Messages, Files, Contracts, Forms, Billing, Knowledge Base, Tasks, and Notes. Before suggesting one or calling its SDK, confirm it is installed — call `listAppInstalls`. If it is installed, use it through the SDK; do not rebuild it. If it is not installed, do not suggest it, do not plan the app around it, and do not ask the user to install it.
|
|
164
166
|
|
|
165
167
|
| Module | What it does | Reach via SDK |
|
|
166
168
|
|---|---|---|
|
|
@@ -172,10 +174,9 @@ Before adding a feature, check whether Assembly already covers it. If it does, *
|
|
|
172
174
|
| Billing | Invoices, subscriptions, payments | `createInvoice`, `listInvoices`, `retrieveInvoice`, `listInvoiceTemplates`, `createSubscription`, `listSubscriptions`, `retrieveSubscription`, `cancelSubscription`, `listSubscriptionTemplates`, `listProducts`, `retrieveProduct`, `listPrices`, `retrievePrice`, `listPayments` |
|
|
173
175
|
| Knowledge Base | Help articles / docs | (no SDK methods today; render-only) |
|
|
174
176
|
| Tasks | Assign tasks to clients/team | `retrieveTasks`, `createTask`, `retrieveTask`, `updateTask`, `deleteTask`, `listTaskTemplates`, `retrieveTaskTemplate` |
|
|
175
|
-
| Notifications | In-app notification feed | `listNotifications`, `createNotification`, `deleteNotification`, `markNotificationRead`, `markNotificationUnread` |
|
|
176
177
|
| Notes | Per-client/company internal notes | `createNote`, `listNotes`, `retrieveNote`, `updateNote`, `deleteNote` |
|
|
177
178
|
|
|
178
|
-
If the user's prompt says "build a CRM," the answer is mostly **wire up the existing Clients + Companies
|
|
179
|
+
If the user's prompt says "build a CRM," the answer is mostly **wire up the existing Clients + Companies** (and any optional modules that are actually installed) **in a custom view** — not invent your own.
|
|
179
180
|
|
|
180
181
|
---
|
|
181
182
|
|
|
@@ -197,6 +198,8 @@ Relationships:
|
|
|
197
198
|
|
|
198
199
|
**Listing companies for users.** `listCompanies` excludes **placeholder** companies (nameless solo-client stand-ins) by default for app-builder apps, so company dropdowns, tables, and filters won't show blank "ghost" rows — you don't need to filter them yourself. To *include* placeholders (e.g. to surface solo clients), pass `listCompanies({ isPlaceholder: true })` explicitly. See `assembly-companies` skill.
|
|
199
200
|
|
|
201
|
+
**App isolation.** `listAppInstalls`/`retrieveAppInstall` tell you which apps are installed (id, appId, type, icon, and other install metadata), and `listAppConnections`/`createAppConnection` manage manual embed and link connections between installed apps — that's real, supported SDK surface, and requests to inspect an install or set up one of those connections are not out of bounds. None of it reaches another app's own data, though: you have no access to another installed app's database, tables, or any other internal data, and there is no SDK call that reads or writes another app's data store. If a request needs another app's private data, say only that it isn't possible and stop — no alternatives and no follow-up question. In user-facing chat, call them apps, not custom apps. Do not propose a bridge: no endpoint or API on the other app, no webhook, push, or sync, and nothing the user has to paste between chats. Do not volunteer automations, clients, companies, custom fields, or an Assembly module as a substitute. If the user explicitly asks to call an existing registered API exposed by another app, that is supported API access rather than database access; never suggest it unprompted. Likewise, if the request itself explicitly asks for automations, CRM data, or an installed module's supported data (for example invoices), that is a separate supported request. Never reach another app's database directly.
|
|
202
|
+
|
|
200
203
|
---
|
|
201
204
|
|
|
202
205
|
## Iframe constraints — read before writing UI
|
|
@@ -332,7 +335,7 @@ Pair it with a server-component or page-level guard so the user sees a friendly
|
|
|
332
335
|
|
|
333
336
|
A Postgres database is provisioned for this app. `DATABASE_URL` is already in your shell environment.
|
|
334
337
|
|
|
335
|
-
Use it for any persistent data that Assembly's
|
|
338
|
+
Use it for any persistent data that Assembly's modules don't cover (custom records, time entries, app-specific state, etc.). **Check the modules section above first** — prefer Assembly SDK data over custom tables when an installed module already covers the need.
|
|
336
339
|
|
|
337
340
|
**`drizzle-orm`, `@neondatabase/serverless`, and `drizzle-kit` are pre-installed. `lib/db.ts`, `lib/schema.ts`, and `drizzle.config.ts` are already in the repo.** Do NOT run `bun add` for these packages or recreate those files.
|
|
338
341
|
|
|
@@ -1155,7 +1158,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1155
1158
|
- ❌ Don't decode the session token client-side. It's encrypted; only the SDK on the server can read it.
|
|
1156
1159
|
- ❌ Don't use `URLSearchParams` on `window.location` to read the token in App Router server components — use the typed `searchParams` prop.
|
|
1157
1160
|
- ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
|
|
1158
|
-
- ❌ Don't reinvent
|
|
1161
|
+
- ❌ Don't reinvent Clients, Companies, or custom fields — they are always available via the SDK. For Messages, Files, Forms, Tasks, Billing, Contracts, Notes, or Knowledge Base: confirm the module is installed (`listAppInstalls`) first; if it is, call the SDK methods listed above rather than rebuilding it. If it is not installed, do not suggest it or plan around it.
|
|
1159
1162
|
- ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
|
|
1160
1163
|
- ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
|
|
1161
1164
|
<!-- !feature:app-owned-header -->
|
|
@@ -1178,6 +1181,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1178
1181
|
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1179
1182
|
- ❌ 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`.
|
|
1180
1183
|
- ❌ 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.
|
|
1184
|
+
- ❌ 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`.
|
|
1181
1185
|
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
1182
1186
|
- ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
|
|
1183
1187
|
- ❌ 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`.
|
|
@@ -1215,5 +1219,5 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1215
1219
|
<!-- !feature:app-owned-header -->
|
|
1216
1220
|
6. **Use `AssemblyBridge.header.*`** for breadcrumbs/CTAs in client components. Don't render your own. (Exception: the notification surface `/n` has no header — render CTAs as inline buttons there.)
|
|
1217
1221
|
<!-- /feature:app-owned-header -->
|
|
1218
|
-
7. **Don't reinvent Messages/Files/Forms/Tasks/Billing
|
|
1222
|
+
7. **Don't reinvent Clients/Companies/custom fields.** For Messages/Files/Forms/Tasks/Billing and similar optional modules, confirm they are installed (`listAppInstalls`) before wiring them via SDK — and skip them if they aren't.
|
|
1219
1223
|
8. **Keep infrastructure plumbing out of chat.** Don't volunteer ASSEMBLY_ENV, COPILOT_ENV, token encryption, API key internals, or internal file names unprompted. Mid-build SDK auth warnings are expected — never surface them to the user. (Answering a user's direct technical question is fine.)
|
|
@@ -18,8 +18,11 @@ const MAX_NOTIFICATION_SETTINGS = 50;
|
|
|
18
18
|
// Setting ids are permanent: they key each internal user's saved toggles and
|
|
19
19
|
// are passed as notificationSettingId on createNotification, so the validator
|
|
20
20
|
// requires stable author-chosen ids instead of letting the platform generate
|
|
21
|
-
// ones that would rotate on every deploy.
|
|
22
|
-
|
|
21
|
+
// ones that would rotate on every deploy. Hyphens and underscores are both
|
|
22
|
+
// stable (a UUID is hyphenated and already matches). Rejecting underscores
|
|
23
|
+
// failed the build when the user or the plan named a category that way
|
|
24
|
+
// (past_due). This check does not rewrite ids already saved on the app.
|
|
25
|
+
const NOTIFICATION_SETTING_ID = /^[a-z0-9]+([_-][a-z0-9]+)*$/;
|
|
23
26
|
|
|
24
27
|
function fail(lines) {
|
|
25
28
|
console.error('');
|
|
@@ -166,10 +169,11 @@ if (doc.notificationSettings !== undefined) {
|
|
|
166
169
|
errors.push(at + ' must be an object with "id", "label", and "surfaces".');
|
|
167
170
|
return;
|
|
168
171
|
}
|
|
169
|
-
if (typeof setting.id !== 'string' || !
|
|
172
|
+
if (typeof setting.id !== 'string' || !NOTIFICATION_SETTING_ID.test(setting.id)) {
|
|
170
173
|
errors.push(
|
|
171
174
|
at +
|
|
172
|
-
'.id is required and must be
|
|
175
|
+
'.id is required and must be lowercase letters, digits, hyphens, or underscores ' +
|
|
176
|
+
'(e.g. "task-completed" or "past_due"). Ids are permanent — ' +
|
|
173
177
|
"they key each internal user's saved toggles and are passed as notificationSettingId " +
|
|
174
178
|
'when creating notifications.',
|
|
175
179
|
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type BatchDb = {
|
|
2
|
+
batch(queries: never): Promise<unknown>;
|
|
3
|
+
};
|
|
4
|
+
export type ReplaceChildRowsResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
ids: string[];
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
type ReplaceChildRowsSpec<TRow extends {
|
|
12
|
+
id?: string;
|
|
13
|
+
}, TDelete extends PromiseLike<unknown>, TUpsert extends PromiseLike<unknown>> = {
|
|
14
|
+
rows: readonly TRow[];
|
|
15
|
+
removeAll: () => TDelete;
|
|
16
|
+
removeMissing: (keepIds: readonly string[]) => TDelete;
|
|
17
|
+
upsert: (row: TRow & {
|
|
18
|
+
id: string;
|
|
19
|
+
}) => TUpsert;
|
|
20
|
+
readIds: () => Promise<ReadonlyArray<{
|
|
21
|
+
id: string;
|
|
22
|
+
}>>;
|
|
23
|
+
};
|
|
24
|
+
export declare function replaceChildRows<TRow extends {
|
|
25
|
+
id?: string;
|
|
26
|
+
}, TDelete extends PromiseLike<unknown>, TUpsert extends PromiseLike<unknown>>(db: BatchDb, spec: ReplaceChildRowsSpec<TRow, TDelete, TUpsert>): Promise<ReplaceChildRowsResult>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Replacing a parent's child rows is where generated apps drop data. The failure
|
|
2
|
+
// that shows up once a parent already has children: insert the new rows, then
|
|
3
|
+
// delete every id that was not in a keep-list built only from rows that already
|
|
4
|
+
// had ids. The insert's id was never on that list, so the same batch deletes it.
|
|
5
|
+
//
|
|
6
|
+
// The neon-http batch method is generic over a one-or-more tuple. Putting that
|
|
7
|
+
// tuple on this parameter makes a real `db` fail to assign, which pushes the
|
|
8
|
+
// cast out to every call site. A function that accepts a specific tuple is
|
|
9
|
+
// assignable to one that accepts `never`, so the cast stays here.
|
|
10
|
+
const childRowsMismatchMessage = 'Save did not keep every row';
|
|
11
|
+
function rowId(row) {
|
|
12
|
+
if (typeof row.id === 'string' && row.id !== '') {
|
|
13
|
+
return row;
|
|
14
|
+
}
|
|
15
|
+
return { ...row, id: crypto.randomUUID() };
|
|
16
|
+
}
|
|
17
|
+
function sameIds(expected, actual) {
|
|
18
|
+
const want = new Set(expected);
|
|
19
|
+
const got = new Set(actual);
|
|
20
|
+
if (want.size !== got.size)
|
|
21
|
+
return false;
|
|
22
|
+
for (const id of want) {
|
|
23
|
+
if (!got.has(id))
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
async function runStatements(db, statements) {
|
|
29
|
+
const [first, ...rest] = statements;
|
|
30
|
+
// One statement is the empty-payload delete. neon-http batch wants two or more.
|
|
31
|
+
if (rest.length === 0) {
|
|
32
|
+
await first;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const batch = db.batch;
|
|
36
|
+
await batch([first, ...rest]);
|
|
37
|
+
}
|
|
38
|
+
export async function replaceChildRows(db, spec) {
|
|
39
|
+
// Ids exist before the delete is built, so a row added in this save is on the keep-list.
|
|
40
|
+
const written = spec.rows.map(rowId);
|
|
41
|
+
const statements = written.length === 0
|
|
42
|
+
? [spec.removeAll()]
|
|
43
|
+
: [spec.removeMissing(written.map((row) => row.id)), ...written.map((row) => spec.upsert(row))];
|
|
44
|
+
await runStatements(db, statements);
|
|
45
|
+
const found = await spec.readIds();
|
|
46
|
+
const ids = written.map((row) => row.id);
|
|
47
|
+
if (!sameIds(ids, found.map((row) => row.id))) {
|
|
48
|
+
return { ok: false, message: childRowsMismatchMessage };
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, ids };
|
|
51
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './app-storage.js';
|
|
|
10
10
|
export * from './labels.js';
|
|
11
11
|
export * from './notifications.js';
|
|
12
12
|
export * from './cron.js';
|
|
13
|
+
export * from './replace-rows.js';
|
|
13
14
|
export * from './api-errors.js';
|
|
14
15
|
export * from './params.js';
|
|
15
16
|
export * from './client.js';
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from './app-storage.js';
|
|
|
14
14
|
export * from './labels.js';
|
|
15
15
|
export * from './notifications.js';
|
|
16
16
|
export * from './cron.js';
|
|
17
|
+
export * from './replace-rows.js';
|
|
17
18
|
// Returns NextResponse, so route handlers only — but nothing here reads a
|
|
18
19
|
// secret, which is why a client file may still `import type` from them.
|
|
19
20
|
export * from './api-errors.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.36",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc && tsc-alias",
|
|
33
|
-
"test": "bun test",
|
|
33
|
+
"test": "bun test && tsc -p tsconfig.replace-rows.json",
|
|
34
34
|
"prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
|
|
35
35
|
"verify:dist": "node scripts/verify-dist-resolves.mjs",
|
|
36
36
|
"verify:bin": "node scripts/verify-bin.mjs"
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@assembly-js/app-bridge": "1.3.0",
|
|
46
46
|
"@assembly-js/node-sdk": "4.2.4",
|
|
47
|
+
"@neondatabase/serverless": "^1.1.0",
|
|
47
48
|
"@types/bun": "^1.1.0",
|
|
48
49
|
"@types/node": "^22",
|
|
50
|
+
"drizzle-orm": "^0.45.2",
|
|
49
51
|
"next": "^15.5.0",
|
|
50
52
|
"server-only": "^0.0.1",
|
|
51
53
|
"tsc-alias": "^1.9.1",
|
|
@@ -334,6 +334,24 @@ await markSubmissionNotified(submission.id);
|
|
|
334
334
|
|
|
335
335
|
Option 2 is cheaper (no list call) and more reliable. Pick option 1 when you don't control the trigger schema.
|
|
336
336
|
|
|
337
|
+
### Recurring conditions
|
|
338
|
+
|
|
339
|
+
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
|
+
|
|
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.
|
|
342
|
+
|
|
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
|
+
```
|
|
352
|
+
|
|
353
|
+
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
|
+
|
|
337
355
|
## Clearing the client portal badge
|
|
338
356
|
|
|
339
357
|
Because clients see a badge counter rather than a Notification Center, every `createNotification({ recipientClientId })` is only half the job — the app must `markNotificationRead` at the right moment or the badge sticks forever. Leaving it unhandled is the most common bug in client-facing apps. There are two patterns:
|
|
@@ -443,6 +461,7 @@ Each client gets their own badge and clears it independently. `recipientCompanyI
|
|
|
443
461
|
## What NOT to do
|
|
444
462
|
|
|
445
463
|
- 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").
|
|
446
465
|
- Do not use `recipientId` — deprecated. Use `recipientClientId` or `recipientInternalUserId`.
|
|
447
466
|
- 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").
|
|
448
467
|
- 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."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-record-writes
|
|
3
|
-
description: REQUIRED when a form creates a persisted record that a later save or update also writes,
|
|
3
|
+
description: REQUIRED when a form creates a persisted record that a later save or update also writes, or when a save replaces nested rows (packages, add-ons, line items, blocks, children). Create and save must call one writer so create cannot drop fields the form submitted. Replacing those rows deletes missing ids before the upsert and reads them back before success. A success toast does not mean the rows were stored.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
A create form often submits the same payload a later edit form saves: parent columns plus nested collections. If create inserts only the parent (and maybe one child table and an audit event) while save writes the rest, the first submit looks successful and the editor opens empty. The redirect after create does not call save.
|
|
@@ -9,31 +9,66 @@ A create form often submits the same payload a later edit form saves: parent col
|
|
|
9
9
|
|
|
10
10
|
One function writes the submitted payload. Create calls it for a new id; save calls it for an existing id.
|
|
11
11
|
|
|
12
|
+
Leave these off the shared payload. They belong to the path that owns them:
|
|
13
|
+
|
|
14
|
+
- ids, timestamps, a draft status, an audit-event row
|
|
15
|
+
- a control flag that chooses an action (for example "send immediately") and is not itself stored, unless save stores that same flag
|
|
16
|
+
- fields that appear only on a later edit form
|
|
17
|
+
|
|
18
|
+
If the form submits it, the create call chain writes it, including by calling the shared writer.
|
|
19
|
+
|
|
20
|
+
## Replacing child rows
|
|
21
|
+
|
|
22
|
+
Import `replaceChildRows` from `@assembly-js/app-foundation/server`. Do not reimplement it. Inserting new rows and then deleting every id missing from a keep-list of only the rows that already had ids deletes the rows just inserted. That only shows up once the parent already has children, so the first save looks fine.
|
|
23
|
+
|
|
24
|
+
When the user adds a row, assign `crypto.randomUUID()` on the client before submit. The helper also assigns any id that arrives empty, before it builds the delete, so a missing id cannot fall off the keep-list.
|
|
25
|
+
|
|
26
|
+
Call the helper only for a collection the form submitted (`input.packages !== undefined`). An omitted collection stays as it is. An empty array means the user cleared that collection: every child of that parent is deleted. The helper uses a delete-all for that case and does not emit `id NOT IN ()`.
|
|
27
|
+
|
|
28
|
+
Upsert the parent by id first. A save does not delete and recreate the parent. Then one `replaceChildRows` per submitted collection. The helper deletes missing ids before the upserts, in one `db.batch` when there is more than one statement. Do not use `db.transaction` (see `assembly-db-transactions`). On conflict, update a child only when it already belongs to this parent, so a submitted id from another record cannot change that record.
|
|
29
|
+
|
|
30
|
+
If the result is `{ ok: false, message }`, return that from the server action. The message is `Save did not keep every row`. Do not toast success. The check compares ids only.
|
|
31
|
+
|
|
12
32
|
```ts
|
|
33
|
+
import { replaceChildRows } from '@assembly-js/app-foundation/server';
|
|
34
|
+
import { and, eq, notInArray } from 'drizzle-orm';
|
|
35
|
+
import { db } from '@/lib/db';
|
|
36
|
+
import { packages, proposals } from '@/lib/schema';
|
|
37
|
+
|
|
13
38
|
async function writeProposal(id: string, input: ProposalInput) {
|
|
14
39
|
await db.insert(proposals).values({ id, title: input.title, intro: input.intro })
|
|
15
40
|
.onConflictDoUpdate({ target: proposals.id, set: { title: input.title, intro: input.intro } });
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
|
|
42
|
+
if (input.packages !== undefined) {
|
|
43
|
+
const replaced = await replaceChildRows(db, {
|
|
44
|
+
rows: input.packages,
|
|
45
|
+
removeAll: () => db.delete(packages).where(eq(packages.proposalId, id)),
|
|
46
|
+
removeMissing: (keepIds) =>
|
|
47
|
+
db.delete(packages).where(and(eq(packages.proposalId, id), notInArray(packages.id, [...keepIds]))),
|
|
48
|
+
upsert: (row) =>
|
|
49
|
+
db.insert(packages).values({ id: row.id, proposalId: id, name: row.name })
|
|
50
|
+
.onConflictDoUpdate({
|
|
51
|
+
target: packages.id,
|
|
52
|
+
set: { name: row.name },
|
|
53
|
+
where: eq(packages.proposalId, id),
|
|
54
|
+
}),
|
|
55
|
+
readIds: () => db.select({ id: packages.id }).from(packages).where(eq(packages.proposalId, id)),
|
|
56
|
+
});
|
|
57
|
+
if (!replaced.ok) return replaced;
|
|
58
|
+
}
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
export async function createProposal(input: ProposalInput) {
|
|
22
62
|
const id = crypto.randomUUID();
|
|
23
|
-
await writeProposal(id, input);
|
|
63
|
+
const written = await writeProposal(id, input);
|
|
64
|
+
if (written) return written;
|
|
24
65
|
await recordEvent(id, 'draft_created');
|
|
25
|
-
return id;
|
|
66
|
+
return { ok: true, id };
|
|
26
67
|
}
|
|
27
68
|
|
|
28
69
|
export async function saveProposal(id: string, input: ProposalInput) {
|
|
29
|
-
await writeProposal(id, input);
|
|
70
|
+
const written = await writeProposal(id, input);
|
|
71
|
+
if (written) return written;
|
|
72
|
+
return { ok: true };
|
|
30
73
|
}
|
|
31
74
|
```
|
|
32
|
-
|
|
33
|
-
Leave these off the shared payload. They belong to the path that owns them:
|
|
34
|
-
|
|
35
|
-
- ids, timestamps, a draft status, an audit-event row
|
|
36
|
-
- a control flag that chooses an action (for example "send immediately") and is not itself stored, unless save stores that same flag
|
|
37
|
-
- fields that appear only on a later edit form
|
|
38
|
-
|
|
39
|
-
If the form submits it, the create call chain writes it, including by calling the shared writer.
|
|
@@ -144,6 +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
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.
|
|
148
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.
|
|
149
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.
|
|
@@ -174,4 +175,5 @@ Insert a row with `nextRunAt` in the past, run that, and confirm the side effect
|
|
|
174
175
|
- ❌ Don't skip `cronRoute` or compare the header yourself.
|
|
175
176
|
- ❌ Don't type, invent, or default `CRON_SECRET`. Declare it; the platform mints it.
|
|
176
177
|
- ❌ Don't call `getAssembly()` in the dispatcher. There is no viewer; use `getOfflineAssembly()`.
|
|
178
|
+
- ❌ Don't notify from a sweep without a per-subject marker. The next tick selects the same rows and sends again.
|
|
177
179
|
- ❌ Don't replace `vercel.json`. Add `crons` to it.
|