|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The **one** table of injected-system-column DEFINITIONS, and the served-document |
| 5 | + * injection / strip pair built on it (objectstack#6562, ruled Option B). |
| 6 | + * |
| 7 | + * ## The split this completes |
| 8 | + * |
| 9 | + * `resolveInjectedSystemColumns` (`@objectstack/spec/data`, #5378) is the one |
| 10 | + * answer to *"WHICH columns does the platform provision on THIS object without |
| 11 | + * the author declaring them?"*. It deliberately owns only the names — #3786's |
| 12 | + * split leaves *"WHAT does each one look like?"* to the runtime. Until now the |
| 13 | + * only copy of that second half lived inside `applySystemFields` |
| 14 | + * (`@objectstack/objectql`), reachable only by running the registry. |
| 15 | + * |
| 16 | + * That is the same wall #4513 hit and recorded one file over |
| 17 | + * ({@link applyAuditFieldGovernance}): `@objectstack/objectql` **depends on** |
| 18 | + * `@objectstack/metadata-protocol`, so the `/meta` read path cannot import from |
| 19 | + * the registry that owns the answer, and the reverse import closes a cycle turbo |
| 20 | + * rejects outright. The honest way out is the one this package already carries |
| 21 | + * twice — sink the contract into a package **both** sides depend on. This |
| 22 | + * package's own dependencies are `{ @objectstack/spec, zod }`, so there is no |
| 23 | + * new edge and no new cycle. `applySystemFields` now reads this table instead of |
| 24 | + * its own literals; the read path reads it too, and the two cannot drift because |
| 25 | + * there is nothing left for them to disagree about. |
| 26 | + * |
| 27 | + * ## Why a `/meta` read needs it at all (#6562) |
| 28 | + * |
| 29 | + * `GET /api/v1/meta/object/:name` answered a **different set of fields** |
| 30 | + * depending on which link of its resolution chain produced the answer: |
| 31 | + * |
| 32 | + * - registry-backed → the schema AFTER `applySystemFields`, carrying |
| 33 | + * `created_at` / `created_by` / `updated_at` / `updated_by` / |
| 34 | + * `organization_id` / `owner_id` / `owning_business_unit_id` even when the |
| 35 | + * author declared none of them; |
| 36 | + * - overlay-backed (a `sys_metadata` row, or a MetadataService body) → the |
| 37 | + * stored document VERBATIM, so every one of those columns was simply absent. |
| 38 | + * |
| 39 | + * Whether an object carries an overlay is invisible to the caller, so the same |
| 40 | + * request reported the platform's own columns or not, and nothing said which had |
| 41 | + * happened. An author reading the overlay-backed answer concludes the columns do |
| 42 | + * not exist — while every one of them is real in the database, filterable, |
| 43 | + * orderable and enforced read-only on write. The maintainer's ruling |
| 44 | + * (2026-08-08) is Option B: the read serves the EFFECTIVE runtime schema, and |
| 45 | + * the overlay-backed minority path converges on the registry-backed majority. |
| 46 | + * |
| 47 | + * ## The one key this table deliberately does NOT carry: `indexed` |
| 48 | + * |
| 49 | + * `applySystemFields` stamps `indexed: <multiTenant>` onto its `organization_id` |
| 50 | + * definition, for the MongoDB driver's schema builder (the only consumer; |
| 51 | + * `driver-mongodb/src/mongodb-schema.ts`). `indexed` is **not a `FieldSchema` |
| 52 | + * key** — it was removed in the 16.x line (#2377, ADR-0049) and `FieldSchema` is |
| 53 | + * `strictObject`, so an object document carrying it is rejected BY NAME: |
| 54 | + * |
| 55 | + * ``` |
| 56 | + * Unrecognized key(s) on this field: `indexed`. |
| 57 | + * • never a FieldSchema key; a field-level index flag built no index (#2377). |
| 58 | + * ``` |
| 59 | + * |
| 60 | + * Measured on `origin/main` (2026-08-08): a registry-backed `/meta` object read |
| 61 | + * therefore already answers `_diagnostics: { valid: false }` on exactly that |
| 62 | + * key, in BOTH multiTenant modes — filed as #6810, and deliberately not |
| 63 | + * inherited here. Converging the overlay-backed exit onto a key the object |
| 64 | + * schema refuses would spread that defect rather than close #6562's; the field |
| 65 | + * SET and every spec-authorable key converge, and the DDL hint stays where the |
| 66 | + * DDL is. `multiTenant` is also the *only* thing that key depends on, which is |
| 67 | + * why nothing in this module takes a `multiTenant` input: per |
| 68 | + * `resolveInjectedSystemColumns`' own measurement, the flag changes whether |
| 69 | + * `organization_id` is INDEXED, never whether it EXISTS. |
| 70 | + */ |
| 71 | + |
| 72 | +import { |
| 73 | + AUDIT_PROVENANCE_FIELDS, |
| 74 | + resolveInjectedSystemColumns, |
| 75 | + type AuditProvenanceField, |
| 76 | +} from '@objectstack/spec/data'; |
| 77 | +import { SystemFieldName } from '@objectstack/spec/system'; |
| 78 | + |
| 79 | +/** |
| 80 | + * Column definitions for the audit-provenance family, keyed by the spec's |
| 81 | + * {@link AUDIT_PROVENANCE_FIELDS} tuple — the canonical declaration of WHICH |
| 82 | + * columns exist (#3786). This table owns only WHAT each column looks like. |
| 83 | + * |
| 84 | + * The `satisfies` clause is the sync mechanism: a name added to the spec tuple |
| 85 | + * without a definition here — or a definition for a name the spec dropped — is |
| 86 | + * a compile error, not a silently diverging copy. Same discipline as the spec's |
| 87 | + * `APPROVER_VALUE_BINDINGS`. |
| 88 | + * |
| 89 | + * Moved here from `@objectstack/objectql`'s registry by #6562; see the module |
| 90 | + * header for why, and {@link AUDIT_FIELD_GOVERNANCE} for the subset of these |
| 91 | + * keys that is forced over a *declared* audit field rather than merely injected |
| 92 | + * in its absence. |
| 93 | + */ |
| 94 | +export const AUDIT_FIELD_DEFS = { |
| 95 | + created_at: { |
| 96 | + type: 'datetime', |
| 97 | + label: 'Created At', |
| 98 | + required: false, |
| 99 | + readonly: true, |
| 100 | + system: true, |
| 101 | + description: 'Timestamp when the record was created (auto-populated by the driver).', |
| 102 | + }, |
| 103 | + created_by: { |
| 104 | + type: 'lookup', |
| 105 | + reference: 'sys_user', |
| 106 | + label: 'Created By', |
| 107 | + required: false, |
| 108 | + readonly: true, |
| 109 | + system: true, |
| 110 | + description: 'User who created the record (populated when an authenticated session is present).', |
| 111 | + }, |
| 112 | + updated_at: { |
| 113 | + type: 'datetime', |
| 114 | + label: 'Last Modified At', |
| 115 | + required: false, |
| 116 | + readonly: true, |
| 117 | + system: true, |
| 118 | + description: 'Timestamp of the most recent modification (auto-populated by the driver).', |
| 119 | + }, |
| 120 | + updated_by: { |
| 121 | + type: 'lookup', |
| 122 | + reference: 'sys_user', |
| 123 | + label: 'Last Modified By', |
| 124 | + required: false, |
| 125 | + readonly: true, |
| 126 | + system: true, |
| 127 | + description: 'User who last modified the record (populated when an authenticated session is present).', |
| 128 | + }, |
| 129 | +} satisfies Record<AuditProvenanceField, Record<string, unknown>>; |
| 130 | + |
| 131 | +/** |
| 132 | + * `organization_id` — THE tenant scope anchor, in its **authorable** shape. |
| 133 | + * |
| 134 | + * ⚠️ `applySystemFields` spreads `indexed: opts.multiTenant` on top of this when |
| 135 | + * it provisions the physical column; see the module header for why that key |
| 136 | + * lives at the injection site and never in a served document. |
| 137 | + */ |
| 138 | +export const TENANT_SCOPE_FIELD_DEF: Readonly<Record<string, unknown>> = { |
| 139 | + type: 'lookup', |
| 140 | + reference: 'sys_organization', |
| 141 | + label: 'Organization', |
| 142 | + required: false, |
| 143 | + hidden: true, |
| 144 | + readonly: true, |
| 145 | + system: true, |
| 146 | + description: |
| 147 | + 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', |
| 148 | +}; |
| 149 | + |
| 150 | +/** |
| 151 | + * `owner_id` — the canonical reassignable owner. `system: true` marks it |
| 152 | + * platform-provided (so tooling/migrations recognise it), but — unlike the audit |
| 153 | + * `*_by` lookups — it is NOT `readonly`: ownership is transferable, so it stays |
| 154 | + * editable in forms and assignable via the API. SecurityPlugin auto-stamps it to |
| 155 | + * the acting user on insert when left NULL. |
| 156 | + */ |
| 157 | +export const OWNER_FIELD_DEF: Readonly<Record<string, unknown>> = { |
| 158 | + type: 'lookup', |
| 159 | + reference: 'sys_user', |
| 160 | + label: 'Owner', |
| 161 | + required: false, |
| 162 | + readonly: false, |
| 163 | + system: true, |
| 164 | + description: |
| 165 | + 'Record owner (auto-stamped to the creating user on insert; reassignable). ' + |
| 166 | + 'Drives owner-scoped views, reports and notifications.', |
| 167 | +}; |
| 168 | + |
| 169 | +/** |
| 170 | + * [ADR-0117 D1] `owning_business_unit_id` — record-level business-unit |
| 171 | + * ownership. Shaped after `organization_id` (a server-stamped scope anchor), NOT |
| 172 | + * after `owner_id` (a user-assignable business field). The full reasoning for |
| 173 | + * each of `readonly` / `hidden` / `required` — and for why the shape presumes |
| 174 | + * nothing about the still-unruled D2 policy — stays at the injection site in |
| 175 | + * `@objectstack/objectql`'s `applySystemFields`, which is where an author of the |
| 176 | + * stamping middleware will be reading. |
| 177 | + */ |
| 178 | +export const OWNING_BUSINESS_UNIT_FIELD_DEF: Readonly<Record<string, unknown>> = { |
| 179 | + type: 'lookup', |
| 180 | + reference: 'sys_business_unit', |
| 181 | + label: 'Owning Business Unit', |
| 182 | + required: false, |
| 183 | + hidden: true, |
| 184 | + readonly: true, |
| 185 | + system: true, |
| 186 | + description: |
| 187 | + 'Record-level business-unit ownership (ADR-0117 D1). Server-stamped scope anchor; ' + |
| 188 | + 'NULL until the stamping middleware lands.', |
| 189 | +}; |
| 190 | + |
| 191 | +/** |
| 192 | + * The injected columns THIS object carries, as `name -> definition`. |
| 193 | + * |
| 194 | + * Gated entirely by {@link resolveInjectedSystemColumns} — every opt-out row |
| 195 | + * (`systemFields: false`, `managedBy: 'better-auth'`, `systemFields.audit: |
| 196 | + * false`, `tenancy.enabled: false`, the per-tier `ownership` table) is answered |
| 197 | + * there and re-derived nowhere. `id` is deliberately absent although the plan |
| 198 | + * reports it: the primary key is provisioned by the DRIVER |
| 199 | + * (`table.string('id').primary()`), not by the injection pass, so no object |
| 200 | + * document declares it and neither exit serves it. |
| 201 | + * |
| 202 | + * Tolerant of bare / un-parsed metadata records, the same contract the plan |
| 203 | + * itself carries. |
| 204 | + */ |
| 205 | +export function injectedSystemColumnDefs(def: unknown): Record<string, Readonly<Record<string, unknown>>> { |
| 206 | + const plan = resolveInjectedSystemColumns(def); |
| 207 | + const defs: Record<string, Readonly<Record<string, unknown>>> = {}; |
| 208 | + if (plan.tenant) defs[SystemFieldName.ORGANIZATION_ID] = TENANT_SCOPE_FIELD_DEF; |
| 209 | + if (plan.audit) for (const name of AUDIT_PROVENANCE_FIELDS) defs[name] = AUDIT_FIELD_DEFS[name]; |
| 210 | + if (plan.owner) defs[SystemFieldName.OWNER_ID] = OWNER_FIELD_DEF; |
| 211 | + if (plan.owningBusinessUnit) defs[SystemFieldName.OWNING_BUSINESS_UNIT_ID] = OWNING_BUSINESS_UNIT_FIELD_DEF; |
| 212 | + return defs; |
| 213 | +} |
| 214 | + |
| 215 | +/** |
| 216 | + * Is this field definition byte-for-byte the platform's own — i.e. a column the |
| 217 | + * INJECTION put there, not something the author wrote? |
| 218 | + * |
| 219 | + * Shallow by construction: every value in the tables above is a primitive, so a |
| 220 | + * key-count check plus strict per-key equality is exact. A nested or extra key |
| 221 | + * therefore fails the comparison, and failure means "the author's field" — the |
| 222 | + * conservative direction, since {@link stripInjectedSystemColumns} only ever |
| 223 | + * removes what matches. A declared `owner_id` carrying the author's own label |
| 224 | + * survives; one that happens to be identical to the platform definition is |
| 225 | + * removed and re-injected identically, which is a no-op by inspection. |
| 226 | + */ |
| 227 | +function isInjectedDefinition(value: unknown, def: Readonly<Record<string, unknown>>): boolean { |
| 228 | + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; |
| 229 | + const rec = value as Record<string, unknown>; |
| 230 | + const keys = Object.keys(rec); |
| 231 | + if (keys.length !== Object.keys(def).length) return false; |
| 232 | + for (const key of keys) if (rec[key] !== def[key]) return false; |
| 233 | + return true; |
| 234 | +} |
| 235 | + |
| 236 | +/** The `fields` record of a metadata document, or `undefined` when it has none. */ |
| 237 | +function fieldsOf(doc: unknown): Record<string, unknown> | undefined { |
| 238 | + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return undefined; |
| 239 | + const fields = (doc as Record<string, unknown>).fields; |
| 240 | + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return undefined; |
| 241 | + return fields as Record<string, unknown>; |
| 242 | +} |
| 243 | + |
| 244 | +/** |
| 245 | + * Add every injected system column the object carries but does not declare, so a |
| 246 | + * served object document reports the EFFECTIVE runtime schema (#6562). |
| 247 | + * |
| 248 | + * The merge direction is `applySystemFields`': injected definitions **lose** to |
| 249 | + * a declared field of the same name, because a declared `owner_id` is the |
| 250 | + * author's field and the registry lets it win. (The audit family's *governance* |
| 251 | + * — the keys that decide who may write it — is the other half, and stays with |
| 252 | + * {@link applyAuditFieldGovernance}: this function only adds absent columns, it |
| 253 | + * never rewrites a declared one.) |
| 254 | + * |
| 255 | + * A document with no `fields` record is returned untouched, deliberately: the |
| 256 | + * write-side {@link stripInjectedSystemColumns} could not tell an emptied |
| 257 | + * `fields: {}` from one that was never there, and the #4326 byte-identical |
| 258 | + * round-trip invariant is what that symmetry protects. |
| 259 | + * |
| 260 | + * Returns the **same reference** when nothing needed adding, so the |
| 261 | + * registry-sourced path (already injected at registration) pays a comparison and |
| 262 | + * no copy. Pure and total — any record may be handed to it. |
| 263 | + */ |
| 264 | +export function applyInjectedSystemColumns<T>(doc: T): T { |
| 265 | + const declared = fieldsOf(doc); |
| 266 | + if (declared === undefined) return doc; |
| 267 | + |
| 268 | + let additions: Record<string, unknown> | undefined; |
| 269 | + for (const [name, def] of Object.entries(injectedSystemColumnDefs(doc))) { |
| 270 | + if (declared[name] !== undefined) continue; |
| 271 | + additions ??= {}; |
| 272 | + additions[name] = { ...def }; |
| 273 | + } |
| 274 | + if (additions === undefined) return doc; |
| 275 | + |
| 276 | + return { |
| 277 | + ...(doc as unknown as Record<string, unknown>), |
| 278 | + fields: { ...additions, ...declared }, |
| 279 | + } as unknown as T; |
| 280 | +} |
| 281 | + |
| 282 | +/** |
| 283 | + * The write-side counterpart of {@link applyInjectedSystemColumns}: remove the |
| 284 | + * injected-but-undeclared columns a served document picked up on its way out, so |
| 285 | + * the standard Studio GET → edit → PUT round-trip still persists a |
| 286 | + * **byte-identical** body (#4326). |
| 287 | + * |
| 288 | + * Same discipline, and the same reason, as `stripReadDecorations` |
| 289 | + * (`@objectstack/spec/kernel`): the write path persists the request body verbatim |
| 290 | + * by design (ADR-0005 §Validation), so anything the READ adds must be removed |
| 291 | + * again on the way in or it is baked into `sys_metadata.metadata`, into its |
| 292 | + * checksum, and into every history diff. It is not the same *list*, though, and |
| 293 | + * must not be folded into that one — a read decoration is derived diagnostics |
| 294 | + * that no schema accepts, whereas these are real, spec-valid field declarations |
| 295 | + * an author may legitimately write. Hence the exactness of |
| 296 | + * {@link isInjectedDefinition}: only a field identical to the platform's own is |
| 297 | + * removed. |
| 298 | + * |
| 299 | + * Returns the **same reference** when nothing needed removing. Pure and total. |
| 300 | + */ |
| 301 | +export function stripInjectedSystemColumns<T>(doc: T): T { |
| 302 | + const declared = fieldsOf(doc); |
| 303 | + if (declared === undefined) return doc; |
| 304 | + |
| 305 | + let kept: Record<string, unknown> | undefined; |
| 306 | + for (const [name, def] of Object.entries(injectedSystemColumnDefs(doc))) { |
| 307 | + if (!isInjectedDefinition(declared[name], def)) continue; |
| 308 | + kept ??= { ...declared }; |
| 309 | + delete kept[name]; |
| 310 | + } |
| 311 | + if (kept === undefined) return doc; |
| 312 | + |
| 313 | + return { ...(doc as unknown as Record<string, unknown>), fields: kept } as unknown as T; |
| 314 | +} |
0 commit comments