Skip to content

Commit 38e170b

Browse files
committed
docs(common-patterns): fix patterns that validate but fail at runtime
All ten patterns were dropped verbatim into a scaffolded 16.0.0-rc.1 app and run through npm run validate — twice: as-published (passes, proving the gate does NOT catch these) and as-fixed. The fixes target runtime-fatal issues invisible to validate: - Both flow patterns lacked the start-node trigger binding (config.objectName + triggerType: 'record-after-update' …) — they validated but never fired; approval-flow nodes (auto_approve / request_approval / update_record) had no config at all, making them no-ops. Bindings, node configs, status: 'active', and a CEL start condition added. - reference: 'user' → 'sys_user' (no 'user' object exists; lookups silently never resolve). - notify recipient '{record.assigned_to.email}' → '{record.assigned_to}' (recipients are user ids; path-walking a string yields empty recipients and a node error). - Formula field: bare 'quantity * unit_price' → 'record.quantity * record.unit_price' + returnType (bare identifiers resolve to null in the CEL scope). - Cursor-pagination section replaced with keyset pagination (cursor is schema-reserved, zero engine/driver execution — same finding as PR #3374). - Frontmatter description no longer promises realtime; import callout gains definePermissionSet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc
1 parent d7578f3 commit 38e170b

1 file changed

Lines changed: 67 additions & 18 deletions

File tree

content/docs/getting-started/common-patterns.mdx

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
---
22
title: Common Patterns
3-
description: Top 10 patterns for building applications with ObjectStack — CRUD, search, auth, realtime, and more
3+
description: Top 10 patterns for building applications with ObjectStack — CRUD, views, flows, agents, security, and more
44
---
55

66
# Common Patterns Guide
77

88
This guide covers the most common patterns you will use when building applications with ObjectStack. Each pattern includes a complete, copy-pasteable example.
99

1010
<Callout type="info">
11-
**Import:** `import { defineStack, defineView, defineApp, defineFlow, defineAgent } from '@objectstack/spec'`
11+
**Import:** `import { defineStack, defineView, defineApp, defineFlow, defineAgent, definePermissionSet } from '@objectstack/spec'`
1212
</Callout>
1313

1414
<Callout type="warn">
@@ -46,7 +46,8 @@ export default defineStack({
4646
{ label: 'Complete', value: 'complete' },
4747
{ label: 'Archived', value: 'archived' }
4848
]},
49-
owner: { label: 'Owner', type: 'lookup', reference: 'user' },
49+
// Person references target the platform user object `sys_user`
50+
owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' },
5051
due_date: { label: 'Due Date', type: 'date' },
5152
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, defaultCurrency: 'USD' } },
5253
}
@@ -73,7 +74,8 @@ export default defineStack({
7374
{ label: 'Complete', value: 'complete' },
7475
{ label: 'Archived', value: 'archived' }
7576
]},
76-
owner: { label: 'Owner', type: 'lookup', reference: 'user' },
77+
// Person references target the platform user object `sys_user`
78+
owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' },
7779
due_date: { label: 'Due Date', type: 'date' },
7880
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, defaultCurrency: 'USD' } },
7981
},
@@ -134,7 +136,8 @@ Create a parent-child relationship between a master and its detail records. Note
134136
product: { label: 'Product', type: 'lookup', reference: 'product' },
135137
quantity: { label: 'Qty', type: 'number', min: 1, required: true },
136138
unit_price: { label: 'Unit Price', type: 'currency' },
137-
line_total: { label: 'Line Total', type: 'formula', expression: 'quantity * unit_price' },
139+
// Formula expressions are CEL — reference fields through `record.`
140+
line_total: { label: 'Line Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' },
138141
}
139142
}
140143
]
@@ -236,7 +239,10 @@ export const crm = defineApp({
236239

237240
## 6. Automated Flow (Record-Triggered)
238241

239-
Create an automation that fires when a record is created or updated.
242+
Create an automation that fires when a record changes. The **start node's `config`
243+
binds the trigger**: `objectName` plus one lifecycle event in `triggerType`
244+
(`record-after-create`, `record-after-update`, `record-after-delete`, or the
245+
`record-before-*` forms) — without it the flow never fires.
240246

241247
```typescript
242248
import { defineFlow } from '@objectstack/spec';
@@ -245,15 +251,23 @@ export const assignmentNotification = defineFlow({
245251
name: 'task_assignment_notification',
246252
label: 'Task Assignment Notification',
247253
type: 'record_change',
254+
status: 'active', // arm the trigger deliberately (default 'draft' is ambiguous)
248255
nodes: [
249-
{ id: 'start', type: 'start', label: 'Start' },
256+
{
257+
id: 'start',
258+
type: 'start',
259+
label: 'Start',
260+
// Trigger binding: which object + which lifecycle event launches this flow
261+
config: { objectName: 'task', triggerType: 'record-after-update' }
262+
},
250263
{
251264
id: 'notify',
252265
type: 'notify',
253266
label: 'Notify Assignee',
254267
config: {
255-
// String fields use single-brace {…} templates, not {{…}}
256-
to: '{record.assigned_to.email}',
268+
// String fields use single-brace {…} templates, not {{…}}.
269+
// `to` takes recipient USER IDs — a lookup value interpolates to the id.
270+
to: '{record.assigned_to}',
257271
subject: 'Task Assigned: {record.title}',
258272
body: 'You have been assigned to task "{record.title}".'
259273
}
@@ -289,8 +303,8 @@ Define a multi-step approval flow for records.
289303
{ label: 'Approved', value: 'approved' },
290304
{ label: 'Rejected', value: 'rejected' }
291305
]},
292-
submitted_by: { label: 'Submitted By', type: 'lookup', reference: 'user' },
293-
approved_by: { label: 'Approved By', type: 'lookup', reference: 'user' },
306+
submitted_by: { label: 'Submitted By', type: 'lookup', reference: 'sys_user' },
307+
approved_by: { label: 'Approved By', type: 'lookup', reference: 'sys_user' },
294308
},
295309
enable: {
296310
trackHistory: true,
@@ -301,11 +315,40 @@ Define a multi-step approval flow for records.
301315
name: 'expense_approval',
302316
label: 'Expense Approval',
303317
type: 'record_change',
318+
status: 'active',
304319
nodes: [
305-
{ id: 'start', type: 'start', label: 'Start' },
320+
{
321+
id: 'start',
322+
type: 'start',
323+
label: 'Start',
324+
config: {
325+
objectName: 'expense_report',
326+
triggerType: 'record-after-update',
327+
// Start condition (bare CEL): launch only on the draft → submitted transition
328+
condition: "record.status == 'submitted' && previous.status != 'submitted'"
329+
}
330+
},
306331
{ id: 'check_amount', type: 'decision', label: 'Check Amount' },
307-
{ id: 'auto_approve', type: 'update_record', label: 'Auto Approve' },
308-
{ id: 'request_approval', type: 'notify', label: 'Request Approval' },
332+
{
333+
id: 'auto_approve',
334+
type: 'update_record',
335+
label: 'Auto Approve',
336+
config: {
337+
objectName: 'expense_report',
338+
filter: { id: '{record.id}' },
339+
fields: { status: 'approved' }
340+
}
341+
},
342+
{
343+
id: 'request_approval',
344+
type: 'notify',
345+
label: 'Request Approval',
346+
config: {
347+
to: '{record.submitted_by}',
348+
subject: 'Expense approval needed: {record.title}',
349+
body: 'Expense "{record.title}" needs manual approval.'
350+
}
351+
},
309352
{ id: 'end', type: 'end', label: 'End' }
310353
],
311354
edges: [
@@ -388,21 +431,27 @@ const page2 = { ...page1, offset: 25 };
388431
const page3 = { ...page1, offset: 50 };
389432
```
390433

391-
### Cursor-Based (Scalable)
434+
### Keyset (Scalable)
435+
436+
Instead of skipping N rows, filter past the last row you already received —
437+
stable under concurrent writes and cheap at any depth. (The query schema also
438+
reserves a `cursor` property, but it is not executed by the engine — use the
439+
`where` + `orderBy` + `limit` form below.)
392440

393441
```typescript
394-
// First page — no cursor yet
442+
// First page
395443
const firstPage = {
396444
object: 'activity',
397445
fields: ['id', 'type', 'description', 'created_at'],
398446
orderBy: [{ field: 'created_at', order: 'desc' }],
399447
limit: 50
400448
};
401449

402-
// Next page — pass the opaque cursor token returned with the previous response
450+
// Next page — filter past the last row of the previous page
451+
const lastRow = previousPage[previousPage.length - 1];
403452
const nextPage = {
404453
...firstPage,
405-
cursor: { /* opaque cursor token from the previous response */ }
454+
where: { created_at: { $lt: lastRow.created_at } }
406455
};
407456
```
408457

0 commit comments

Comments
 (0)