Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/33207-seed-org-scoped-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol)!: a seed load for one organization resolves people through membership in that organization, says when a reference lookup was refused, and gives hooks on seeded rows the organization the rows are stamped with

Clause-②: yes (narrowing) — narrowed: under a walled tenancy posture (`isolated` or `group`), every read and by-id write a seed write performs or triggers (a hook's `ctx.api` read, the engine's own reads on that row such as reference checks and roll-ups, and the loader's own update of an existing row and its pass-2 back-fill) is scoped to the organization the row is written as, where it reached every organization's rows. Widened: on a load for one organization (the per-organization replay, a reseed, an install into an organization), a reference to a person (`owner_id`, a `sys_user` lookup) resolves to a person who is a member of that organization, where it never resolved; a reference to `sys_organization` resolves to the load's own organization; a reference to an object no organization walls (no `organization_id` column and no tenant field at all, e.g. `tenancy: { enabled: false }`) resolves across organizations; and a hook on a seeded row now sees the organization the row is stamped with as `ctx.session.organizationId`, on those loads and on every load that stamps the install's sole organization (the boot under `single`, a publish, a hot reload). Nothing that resolved before stops resolving, and no load that succeeded is refused.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed, renamed or re-shaped and no stored row changes shape: what moves is which rows a hook reads through its own api while a seed load for one organization writes, so there is nothing for a migration to rewrite and no tombstone -->

**BREAKING** for an app hook that reads through `ctx.api` on a seeded row under a walled tenancy posture and relied on seeing other organizations' rows. A seed load for organization A now writes as organization A: the hook, and every engine read the write triggers, reads what it reads when a person of organization A creates the row through the API. A row that supplies its own `organization_id` is written as that organization instead. The fix: a hook that genuinely needs rows of every organization says so with `ctx.api.sudo()` and filters them itself, as it must on the API path already.

What changes, by load:

- **A reference to a person on an organization's own load.** `sys_user` carries no organization (identity lives above it), so the loader's reference lookup no longer filters it by `organization_id`, which the database refused. It reads the people the natural key names, in id order and page by page, and keeps the first one holding a `sys_member` row in the load's organization. A person who exists but is not a member is never used: the reference stays empty, and only once every person the key names has been asked about does its error say the person is not a member of that organization.
- **Every other reference target** is judged from its registered schema: one with an `organization_id` column is looked up inside the load's organization, as before; `sys_organization` answers only for the load's own organization; an author's object that resolves no tenant field at all has no organization filter. An object walled by another tenant column (`tenancy.tenantField`) and a platform-managed table with no organization column keep the old lookup, and its refusal is now reported.
- **A refused lookup** (an unknown column, an unregistered object, a driver fault) is no longer read as "no match". The load logs it once per target, field and code, keeps going, and the reference's error names the refusal's code instead of saying no such row exists. The load's outcome is unchanged: the reference was unresolved before and stays unresolved.
- **The write context.** Every seed write of a load carries `tenantId` set to the organization the row is written as: the one it supplies itself, else the one the load stamps it with (the one the caller pinned, or the install's sole organization). The shared `SEED_WRITE_EXECUTION_CONTEXT` is unchanged. A load with no organization, pinned or derived, writes with exactly the context it used before.
- **A pass-2 back-fill that lands on no row** (a data engine answering `null` for the by-id update) is reported as a failed back-fill, never counted as resolved.
- **A load with no organization** (the single-tenant boot, a publish, a hot reload) resolves references across the whole database, as before.
26 changes: 26 additions & 0 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,26 @@ const contactsSeed = defineSeed(Contact, {
export const SeedData = [accountsSeed, contactsSeed];
```

### References on an organization's own load

A load that seeds one organization (the per-organization replay, a reseed, a
package install into an organization) resolves a natural key **inside that
organization**, by what the target object carries:

| Target | Resolves to |
|:---|:---|
| An object with an `organization_id` column (every business object) | a row of the load's organization only; another organization's row with the same key is never used |
| `sys_user` (people: `owner_id`, people lookups) | a person who is a **member** of the load's organization (a `sys_member` row there); a same-named person outside it is never used |
| `sys_organization` | the load's own organization only |
| An object no organization walls: no `organization_id` column and no tenant field (for example `tenancy: { enabled: false }`) | any row: global rows belong to every organization |
| An object walled by another tenant column (`tenancy.tenantField`) with no `organization_id` column | nothing on such a load: the lookup is refused and reported, never answered from another organization |

A load with no organization (the single-tenant boot) resolves across the whole
database, as it always has. When the database refuses a lookup outright (for
example a column the target does not have), the load logs the refusal once,
with its code, and the reference's error says the lookup was refused instead
of claiming the row does not exist.

### Multi-Value Lookups (`multiple: true`)

A lookup declared `multiple: true` stores an **array** of related record ids, so its
Expand Down Expand Up @@ -628,6 +648,12 @@ same way. A row someone already owns is never touched.
passes `config.identity`).
- `os.org.id` resolves to the current organization; during a per-tenant replay
it is that tenant's id, falling back to the load's `organizationId`.
- Hooks run on seeded rows (seeds skip only flow automation), and a hook sees
the organization the row is stamped with as `ctx.session.organizationId`,
the same as for a row written through the API (a row that supplies its own
`organization_id` is written as that organization). Under a walled tenancy
posture its `ctx.api` reads, and every engine read the write triggers, are
scoped to that organization. A load with no organization gives hooks none.

### Failure is loud, not silent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,39 @@ describe('seed deferred back-fill failure is reported, not swallowed (framework#
expect(result.errors.some((e: { field: string }) => e.field === 'head_id')).toBe(true);
});

/**
* #33206 — the data-engine contract lets `update` answer `null` for a by-id
* update that matched no row (the row is gone, or the organization the write
* carries cannot see it). The link was not written, so the back-fill is a
* failure like a thrown one, never counted as resolved.
*/
it('a pass-2 reference update that matched no row (null) is reported, not counted as resolved (#33206)', async () => {
const { engine, store } = createFaithfulEngine();
const metadata = createMetadata();

const realUpdate = (engine.update as any).getMockImplementation();
(engine.update as any).mockImplementation(async (obj: string, data: any, opts: any) => {
if (obj === 'audit_department') return null;
return realUpdate(obj, data, opts);
});

const logger = createLogger();
const result = await new SeedLoaderService(engine, metadata, logger).load({
seeds: SEEDS,
config: CONFIG,
});

expect(store.audit_department.find((r) => r.name === 'Engineering')!.head_id == null).toBe(true);
expect(result.success).toBe(false);
expect(result.errors.some((e: { field: string }) => e.field === 'head_id')).toBe(true);
const line = logger.error.mock.calls
.map((c: unknown[]) => String(c[0]))
.find((m: string) => m.includes('audit_department.head_id'));
expect(line).toContain('matched no row');
const dept = result.results.find((r: { object: string }) => r.object === 'audit_department');
expect(dept?.referencesResolved ?? 0).toBe(0);
});

/**
* #4729 — the LOG LEVEL has to agree with the count.
*
Expand Down
Loading
Loading