Skip to content

Commit 5e831de

Browse files
fix(seed): bind seed records to users via os.user + fail loudly on unresolved refs (#1392)
Fixes #1389. Seed records can now bind to a platform user with `cel`os.user.id`` (and to the org with `cel`os.org.id``), which previously never resolved at boot — making objects with a required lookup('user') un-seedable, and silently dropping the records. - Resolve os.user / os.org: thread a new SeedLoaderConfig.identity through the CEL eval context in the seed loader (os.org falls back to organizationId). - Deterministic seed owner: AppPlugin provisions a non-loginable system user (usr_system, role `system`) before any seed runs and binds it as os.user, so identity-derived seeds resolve on a fresh boot — before the first human sign-up. The better-auth login admin stays separate (ADR-0010 respected). Exposed as SystemUserId.SYSTEM. - Loud failures: unresolved CEL values and write failures are now counted as errors (result.success === false) with actionable messages, instead of being silently dropped — in both the inline run and the per-tenant replayer. - Docs: seed-data guide documents the os.user binding, the usr_system ordering guarantee, and the loud-failure behavior. Tests: 4 new seed-identity cases; full runtime suite 307 passing. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 58b450b commit 5e831de

7 files changed

Lines changed: 475 additions & 18 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
Seed data: first-class identity binding + loud failures (fixes #1389)
7+
8+
Records seeded via `defineDataset` / `defineStack({ data })` can now bind to a
9+
platform user with `cel\`os.user.id\`` (and to the org with `cel\`os.org.id\``),
10+
which previously never resolved at boot.
11+
12+
- **`os.user` / `os.org` now actually resolve.** The runtime provisions a
13+
deterministic, non-loginable system user (`usr_system`, role `system`)
14+
*before* any seed runs and binds it to `os.user`, so identity-derived seed
15+
values resolve even on a fresh boot — before the first human sign-up. The
16+
human login admin remains a separate better-auth identity and need not own
17+
seed data. Exposed as the canonical `SystemUserId.SYSTEM` constant.
18+
- **New `SeedLoaderConfig.identity`** carries the `os.user` / `os.org` subject
19+
into CEL evaluation (`@objectstack/spec`).
20+
- **Failures are loud, not silent.** A record whose CEL value can't resolve
21+
(e.g. a required `cel\`os.user.id\`` with no identity) — or that fails to
22+
write — is now counted as an error, marks the load unsuccessful, and logs an
23+
actionable message, instead of being silently dropped.

content/docs/guides/seed-data.mdx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,84 @@ export const SeedData = [accountsSeed, contactsSeed];
203203

204204
---
205205

206+
## Dynamic Values (CEL)
207+
208+
Any field value may be a **CEL expression** evaluated at install time against a
209+
single per-load pinned `now`. This is the only correct way to author time-based
210+
or identity-derived seed values — a literal `new Date()` would ship the package
211+
author's clock to every customer and break build determinism.
212+
213+
```typescript
214+
import { defineDataset, cel } from '@objectstack/spec';
215+
216+
defineDataset(Opportunity, {
217+
records: [{
218+
name: 'Acme Q3 Renewal',
219+
close_date: cel`daysFromNow(45)`,
220+
created_at: cel`now()`,
221+
owner_id: cel`os.user.id`, // the seed identity
222+
organization_id: cel`os.org.id`,
223+
}],
224+
});
225+
```
226+
227+
Available in the seed CEL context:
228+
229+
- **Functions:** `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`,
230+
`isBlank(v)`, `coalesce(v, fallback)`
231+
- **Scope:** `os.user`, `os.org`, `os.env`
232+
233+
### Binding records to a user (`os.user`)
234+
235+
Many objects have a **required** owner lookup — `owner_id`, `created_by`,
236+
`assigned_to`. To seed such a record, bind it to a user with `cel\`os.user.id\``.
237+
This is the single canonical convention; there is no `currentUser()`, `@admin`,
238+
or similar special syntax.
239+
240+
```typescript
241+
defineDataset(Project, {
242+
externalId: 'code',
243+
records: [{
244+
code: 'bootstrap',
245+
name: 'Bootstrap Project',
246+
owner_id: cel`os.user.id`, // ← bound to the seed identity
247+
}],
248+
});
249+
```
250+
251+
**Where does `os.user` come from?** On a fresh boot there are no human users yet
252+
— seeding runs *before* the first sign-up. So the runtime provisions a
253+
deterministic, non-loginable **system user** (`usr_system`, role `system`)
254+
*before* any seed runs and binds it to `os.user`. It owns seeded data the way
255+
Salesforce's "Automated Process" user does — it has no credential and **cannot
256+
sign in**.
257+
258+
- The **human login admin** is created separately (CLI sign-up / first-signup
259+
promotion) through better-auth and need **not** be the seed owner.
260+
- `os.org.id` resolves to the current organization; during a per-tenant replay
261+
it is that tenant's id, falling back to the load's `organizationId`.
262+
263+
This ordering guarantee means `cel\`os.user.id\`` / `cel\`os.org.id\`` always
264+
resolve at boot — you never have to sequence seeds around user creation.
265+
266+
### Failure is loud, not silent
267+
268+
If a record uses a CEL value that cannot be resolved — e.g. `cel\`os.user.id\``
269+
when the system identity could not be provisioned — the record is **not silently
270+
dropped**. The loader counts it as an error, marks the load unsuccessful, and
271+
logs an actionable message:
272+
273+
```
274+
[SeedLoader] Cannot resolve dynamic seed values for project record #0:
275+
... Records using cel`os.user.id` / cel`os.org.id` require a seed identity —
276+
ensure a system/admin user exists before seeding.
277+
```
278+
279+
Write failures (e.g. a required field still missing after resolution) are
280+
surfaced the same way. Tooling should check `result.success` / `result.errors`.
281+
282+
---
283+
206284
## Organising Multiple Datasets
207285

208286
For applications with several objects, co-locate seed files under `src/data/` and

packages/runtime/src/app-plugin.ts

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { readEnvWithDeprecation } from '@objectstack/types';
55
import { SeedLoaderService } from './seed-loader.js';
66
import { loadDisabledPackageIds } from './package-state-store.js';
77
import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
8+
import { SystemUserId } from '@objectstack/spec/system';
89
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
910
import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js';
1011

@@ -482,6 +483,13 @@ export class AppPlugin implements Plugin {
482483
object: d.object,
483484
}));
484485

486+
// Resolve the seed identity (os.user / os.org) BEFORE any seed
487+
// runs. Deterministically ensures a non-loginable system user
488+
// exists so identity-derived seed values (e.g.
489+
// `owner_id: cel`os.user.id``) resolve at boot — before the
490+
// first human sign-up. See ensureSeedIdentity().
491+
const seedIdentity = await this.ensureSeedIdentity(ql, ctx.logger);
492+
485493
// Stash datasets on a kernel service so SecurityPlugin's
486494
// sys_organization insert hook can replay them per-tenant
487495
// (Salesforce-sandbox style: every new org gets its own
@@ -529,6 +537,11 @@ export class AppPlugin implements Plugin {
529537
defaultMode: 'upsert',
530538
multiPass: true,
531539
organizationId,
540+
// Bind os.user (system identity) and os.org (this
541+
// tenant) so identity-derived seed values resolve
542+
// per-org. org.id falls back to organizationId
543+
// inside the loader when identity.org is absent.
544+
identity: seedIdentity,
532545
},
533546
});
534547
const result = await seedLoader.load(request);
@@ -571,14 +584,38 @@ export class AppPlugin implements Plugin {
571584
const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data');
572585
const request = SeedLoaderRequestSchema.parse({
573586
datasets: normalizedDatasets,
574-
config: { defaultMode: 'upsert', multiPass: true },
587+
config: { defaultMode: 'upsert', multiPass: true, identity: seedIdentity },
575588
});
576589
const result = await seedLoader.load(request);
577-
ctx.logger.info('[Seeder] Seed loading complete', {
578-
inserted: result.summary.totalInserted,
579-
updated: result.summary.totalUpdated,
580-
errors: result.errors.length,
581-
});
590+
const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary;
591+
if (result.success) {
592+
ctx.logger.info('[Seeder] Seed loading complete', {
593+
inserted: totalInserted,
594+
updated: totalUpdated,
595+
skipped: totalSkipped,
596+
errored: totalErrored,
597+
});
598+
} else {
599+
// LOUD FAILURE: dropped records were previously
600+
// invisible (the summary only logged errors.length and
601+
// omitted totalErrored). Report the count AND each
602+
// actionable reason so broken seeds can't pass silently.
603+
ctx.logger.warn(
604+
`[Seeder] Seed loading completed with ${totalErrored} dropped record(s) and ${result.errors.length} error(s) for ${appId}`,
605+
{
606+
inserted: totalInserted,
607+
updated: totalUpdated,
608+
skipped: totalSkipped,
609+
errored: totalErrored,
610+
},
611+
);
612+
for (const e of result.errors.slice(0, 20)) {
613+
ctx.logger.warn(`[Seeder] ✗ ${e.message}`);
614+
}
615+
if (result.errors.length > 20) {
616+
ctx.logger.warn(`[Seeder] …and ${result.errors.length - 20} more error(s)`);
617+
}
618+
}
582619
} else {
583620
// Fallback: basic insert when metadata service is not available
584621
ctx.logger.debug('[Seeder] No metadata service; using basic insert fallback');
@@ -633,6 +670,72 @@ export class AppPlugin implements Plugin {
633670
this.emitCatalogEvent(ctx, 'app:unregistered', sys);
634671
}
635672

673+
/**
674+
* Resolve the identity bound to `os.user` / `os.org` for seed CEL values.
675+
*
676+
* On a fresh boot there are zero users until the first human sign-up
677+
* (which the SeedLoader runs *before*), so identity-derived seeds like
678+
* `owner_id: cel`os.user.id`` had nothing to resolve against and were
679+
* dropped silently. To make seeds deterministic and self-sufficient we
680+
* upsert a single non-loginable **system user** (`usr_system`) and bind
681+
* it as `os.user`.
682+
*
683+
* Why a dedicated system user rather than the login admin:
684+
* - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
685+
* password lives in `sys_account`, so a *loginable* admin can only be
686+
* minted through better-auth (the CLI does this via HTTP sign-up after
687+
* boot). A raw insert here would bypass those invariants.
688+
* - `usr_system` is an owner identity only (no credential row), analogous
689+
* to Salesforce's "Automated Process" user. The human admin is created
690+
* independently and need not be the seed owner.
691+
*
692+
* Idempotent: matches by the stable id, inserts once, reuses thereafter.
693+
* Failures are non-fatal (logged) — records that actually need `os.user`
694+
* then fail loudly in the loader with an actionable message.
695+
*/
696+
private async ensureSeedIdentity(
697+
ql: any,
698+
logger: PluginContext['logger'],
699+
): Promise<{ user: { id: string; role: string; email: string } }> {
700+
// Deterministic, non-loginable service identity that owns seeded data.
701+
const SYSTEM_USER_ID = SystemUserId.SYSTEM;
702+
const SYSTEM_USER_EMAIL = 'system@objectstack.local';
703+
const identity = { user: { id: SYSTEM_USER_ID, role: 'system', email: SYSTEM_USER_EMAIL } };
704+
const opts = { context: { isSystem: true } } as any;
705+
706+
try {
707+
const existing = await (ql as any).find(
708+
'sys_user',
709+
{ where: { id: SYSTEM_USER_ID }, limit: 1 },
710+
opts,
711+
);
712+
if (Array.isArray(existing) && existing.length > 0) {
713+
return identity;
714+
}
715+
await (ql as any).insert(
716+
'sys_user',
717+
{
718+
id: SYSTEM_USER_ID,
719+
name: 'System',
720+
email: SYSTEM_USER_EMAIL,
721+
email_verified: true,
722+
role: 'system',
723+
},
724+
opts,
725+
);
726+
logger.info(
727+
`[Seeder] Provisioned deterministic system user (${SYSTEM_USER_ID}) as seed owner — binds os.user for identity-derived seed values`,
728+
);
729+
} catch (err: any) {
730+
// Non-fatal: identity-dependent records will fail loudly in the
731+
// loader; identity-free records still seed normally.
732+
logger.warn('[Seeder] Failed to ensure system seed user; os.user-dependent seeds may be dropped', {
733+
error: err?.message ?? String(err),
734+
});
735+
}
736+
return identity;
737+
}
738+
636739
/**
637740
* Emit a kernel hook so the control-plane `AppCatalogService` can
638741
* upsert / delete the corresponding `sys_app` row. Silently no-ops

packages/runtime/src/seed-loader.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,138 @@ describe('SeedLoaderService', () => {
10151015
// load — edge cases
10161016
// ========================================================================
10171017

1018+
describe('load — seed identity (os.user / os.org)', () => {
1019+
// CEL Expression envelope helper — same shape the spec persists.
1020+
const cel = (source: string) => ({ dialect: 'cel', source });
1021+
1022+
const baseConfig = (extra: Record<string, any> = {}): SeedLoaderConfig => ({
1023+
dryRun: false,
1024+
haltOnError: false,
1025+
multiPass: false,
1026+
defaultMode: 'upsert',
1027+
batchSize: 1000,
1028+
transaction: false,
1029+
...extra,
1030+
} as SeedLoaderConfig);
1031+
1032+
it('resolves cel`os.user.id` from config.identity', async () => {
1033+
const metadata = createMockMetadata({
1034+
note: { name: 'note', fields: { name: { type: 'text' }, author: { type: 'text' } } },
1035+
});
1036+
const engine = createMockEngine();
1037+
const loader = new SeedLoaderService(engine, metadata, logger);
1038+
1039+
const result = await loader.load({
1040+
datasets: [
1041+
{
1042+
object: 'note',
1043+
externalId: 'name',
1044+
mode: 'insert',
1045+
env: ['prod', 'dev', 'test'],
1046+
records: [{ name: 'N1', author: cel('os.user.id') }],
1047+
},
1048+
],
1049+
config: baseConfig({
1050+
identity: { user: { id: 'usr_system', role: 'system', email: 'system@objectstack.local' } },
1051+
}),
1052+
});
1053+
1054+
expect(result.success).toBe(true);
1055+
expect(result.summary.totalErrored).toBe(0);
1056+
expect(engine.insert).toHaveBeenCalledWith(
1057+
'note',
1058+
expect.objectContaining({ name: 'N1', author: 'usr_system' }),
1059+
expect.anything(),
1060+
);
1061+
});
1062+
1063+
it('fails loudly (no raw envelope written) when os.user is unbound', async () => {
1064+
const metadata = createMockMetadata({
1065+
note: { name: 'note', fields: { name: { type: 'text' }, author: { type: 'text' } } },
1066+
});
1067+
const engine = createMockEngine();
1068+
const loader = new SeedLoaderService(engine, metadata, logger);
1069+
1070+
const result = await loader.load({
1071+
datasets: [
1072+
{
1073+
object: 'note',
1074+
externalId: 'name',
1075+
mode: 'insert',
1076+
env: ['prod', 'dev', 'test'],
1077+
records: [{ name: 'N1', author: cel('os.user.id') }],
1078+
},
1079+
],
1080+
// No identity → os.user unbound → record must be dropped, not written.
1081+
config: baseConfig(),
1082+
});
1083+
1084+
expect(result.success).toBe(false);
1085+
expect(result.summary.totalErrored).toBe(1);
1086+
expect(result.errors).toHaveLength(1);
1087+
expect(result.errors[0].message).toContain('os.user');
1088+
// Critically: the unresolved Expression envelope is NEVER persisted.
1089+
expect(engine.insert).not.toHaveBeenCalled();
1090+
});
1091+
1092+
it('falls back os.org.id to organizationId during per-tenant replay', async () => {
1093+
const metadata = createMockMetadata({
1094+
note: { name: 'note', fields: { name: { type: 'text' }, org_label: { type: 'text' } } },
1095+
});
1096+
const engine = createMockEngine();
1097+
const loader = new SeedLoaderService(engine, metadata, logger);
1098+
1099+
const result = await loader.load({
1100+
datasets: [
1101+
{
1102+
object: 'note',
1103+
externalId: 'name',
1104+
mode: 'insert',
1105+
env: ['prod', 'dev', 'test'],
1106+
records: [{ name: 'N1', org_label: cel('os.org.id') }],
1107+
},
1108+
],
1109+
config: baseConfig({ organizationId: 'org_123' }),
1110+
});
1111+
1112+
expect(result.success).toBe(true);
1113+
expect(engine.insert).toHaveBeenCalledWith(
1114+
'note',
1115+
expect.objectContaining({ org_label: 'org_123' }),
1116+
expect.anything(),
1117+
);
1118+
});
1119+
1120+
it('surfaces write failures in result.errors (loud, not just counted)', async () => {
1121+
const metadata = createMockMetadata({
1122+
account: { name: 'account', fields: { name: { type: 'text' } } },
1123+
});
1124+
const engine = createMockEngine();
1125+
engine.insert = vi.fn(async () => {
1126+
throw new Error('boom');
1127+
});
1128+
const loader = new SeedLoaderService(engine, metadata, logger);
1129+
1130+
const result = await loader.load({
1131+
datasets: [
1132+
{
1133+
object: 'account',
1134+
externalId: 'name',
1135+
mode: 'insert',
1136+
env: ['prod', 'dev', 'test'],
1137+
records: [{ name: 'Acme' }],
1138+
},
1139+
],
1140+
config: baseConfig({ defaultMode: 'insert' }),
1141+
});
1142+
1143+
expect(result.success).toBe(false);
1144+
expect(result.summary.totalErrored).toBe(1);
1145+
expect(result.errors.length).toBeGreaterThan(0);
1146+
expect(result.errors[0].message).toContain('Failed to write');
1147+
});
1148+
});
1149+
10181150
describe('load — edge cases', () => {
10191151
it('should handle records with no matching externalId field', async () => {
10201152
const metadata = createMockMetadata({

0 commit comments

Comments
 (0)