|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The generated object draft has to survive the platform's OWN validator. |
| 5 | + * |
| 6 | + * `generateObjectDraft` renders a `*.object.ts` a human is meant to review and |
| 7 | + * commit. Two things in that output made it un-committable: |
| 8 | + * |
| 9 | + * 1. the object name carried no `${namespace}_` prefix, so `defineStack()` |
| 10 | + * refused it outright (ADR-0028) — measured on the pre-fix tree as |
| 11 | + * `Object 'customers' is missing the package namespace prefix.`; |
| 12 | + * 2. no `sharingModel` was emitted, so the author-time rule set `os build` |
| 13 | + * runs refused it (`security-owd-unset` at `objects[0].sharingModel`, |
| 14 | + * ADR-0090 D1) — the same rule family #9666 hit for the `os init` template. |
| 15 | + * |
| 16 | + * ## Why the two are pinned SEPARATELY |
| 17 | + * |
| 18 | + * A single "the draft builds now" assertion cannot say which of the two it is |
| 19 | + * measuring, and cannot fail informatively when one of them regresses on its |
| 20 | + * own. Each defect therefore gets its own case, asserting its own signal. |
| 21 | + * |
| 22 | + * ## Why `still-generates` is here at all |
| 23 | + * |
| 24 | + * Both defects above are satisfiable by emitting LESS. An implementation that |
| 25 | + * returned a minimal valid stub — right name, right OWD, no fields — would go |
| 26 | + * green on a validator-only suite while destroying the only thing this |
| 27 | + * generator exists to do. The `still-generates` block is the counterweight: |
| 28 | + * the introspected columns, the remote table name and the `external` binding |
| 29 | + * are asserted to survive the fix. |
| 30 | + * |
| 31 | + * ## The instrument |
| 32 | + * |
| 33 | + * The prefix assertion calls `validateObjectNamespacePrefix` — the same |
| 34 | + * function `defineStack()` and the runtime publish gate call — rather than |
| 35 | + * re-spelling `startsWith`. A hand-rolled check here could pass while the real |
| 36 | + * gate refuses, which is precisely the drift that produced defect (1). |
| 37 | + * The OWD assertion runs a full `ObjectSchema.safeParse`, because what is |
| 38 | + * being guarded is a VALUE's verdict, not merely a key's presence. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect } from 'vitest'; |
| 42 | +import type { IntrospectedSchema } from '@objectstack/spec/contracts'; |
| 43 | +import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; |
| 44 | +import { ObjectSchema } from '@objectstack/spec/data'; |
| 45 | +import { |
| 46 | + ExternalDatasourceService, |
| 47 | + type DatasourceLike, |
| 48 | + type ExternalDatasourceServiceConfig, |
| 49 | +} from '../external-datasource-service.js'; |
| 50 | + |
| 51 | +/** |
| 52 | + * A remote schema with an ALREADY-prefixed table alongside a bare one, so the |
| 53 | + * double-prefix case (`wh_wh_accounts`) is reachable from the same fixture. |
| 54 | + * |
| 55 | + * Hand-written rather than driven off a live `SqlDriver` — deliberately, and |
| 56 | + * for a different reason than the blindness `external-introspection-seam.test.ts` |
| 57 | + * exists to close. Neither defect pinned here reads a column's PK-ness at all: |
| 58 | + * the object NAME comes from the table name and the OWD is a constant, so a |
| 59 | + * real introspection would add cost and no coverage. |
| 60 | + * |
| 61 | + * Every column is spelled `primaryKey: false` on purpose. That keeps the whole |
| 62 | + * file on the `opts.primaryKey`-unset path, where the generator emits no |
| 63 | + * `fields.<f>.primaryKey` — the key that is NOT authorable (#11000, an open |
| 64 | + * contract question in `packages/spec`, deliberately untouched here). Pinning |
| 65 | + * these two repairs on a draft that also carries #11000's key would produce |
| 66 | + * cases that cannot go green until a card this lane does not own is decided. |
| 67 | + */ |
| 68 | +function remoteSchema(): IntrospectedSchema { |
| 69 | + return { |
| 70 | + dialect: 'postgres', |
| 71 | + introspectedAt: '2026-08-22T00:00:00.000Z', |
| 72 | + tables: { |
| 73 | + 'mart.customers': { |
| 74 | + name: 'mart.customers', |
| 75 | + indexes: [], |
| 76 | + columns: [ |
| 77 | + { name: 'id', type: 'text', nullable: false, primaryKey: false }, |
| 78 | + { name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false }, |
| 79 | + { name: 'signed_up_at', type: 'timestamptz', nullable: true, primaryKey: false }, |
| 80 | + ], |
| 81 | + }, |
| 82 | + 'mart.wh_accounts': { |
| 83 | + name: 'mart.wh_accounts', |
| 84 | + indexes: [], |
| 85 | + columns: [{ name: 'id', type: 'text', nullable: false, primaryKey: false }], |
| 86 | + }, |
| 87 | + }, |
| 88 | + }; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * The service wired exactly as `plugin.ts` wires it, with the namespace |
| 93 | + * resolution the plugin injects made explicit per-test. |
| 94 | + * |
| 95 | + * `namespace: undefined` is NOT the same as omitting `getNamespace`: the first |
| 96 | + * is "a resolver ran and found nothing", the second is "no resolver at all". |
| 97 | + * Both must land on the bare name, and both are exercised below. |
| 98 | + */ |
| 99 | +function serviceWith( |
| 100 | + namespace?: string | undefined, |
| 101 | + opts: { wireResolver?: boolean } = {}, |
| 102 | +): ExternalDatasourceService { |
| 103 | + const config: ExternalDatasourceServiceConfig = { |
| 104 | + introspect: async () => remoteSchema(), |
| 105 | + getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }), |
| 106 | + getObject: async () => undefined, |
| 107 | + listObjects: async () => [], |
| 108 | + ...(opts.wireResolver === false ? {} : { getNamespace: () => namespace }), |
| 109 | + }; |
| 110 | + return new ExternalDatasourceService(config); |
| 111 | +} |
| 112 | + |
| 113 | +/** The canonical OWD values, read off the live schema rather than restated. */ |
| 114 | +const CANONICAL_OWD: readonly string[] = ( |
| 115 | + ObjectSchema.shape.sharingModel as unknown as { def: { innerType: { options: string[] } } } |
| 116 | +).def.innerType.options; |
| 117 | + |
| 118 | +describe('defect 1 — the generated object name carries the package namespace prefix', () => { |
| 119 | + it('prefixes the derived name, and the SINGLE-SOURCE rule accepts it', async () => { |
| 120 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 121 | + |
| 122 | + expect(draft.name).toBe('wh_customers'); |
| 123 | + // The instrument that matters: the very function `defineStack()` calls. |
| 124 | + expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull(); |
| 125 | + // …and the rendered file agrees with the structured definition. |
| 126 | + expect(draft.definition.name).toBe('wh_customers'); |
| 127 | + expect(draft.source).toContain("name: 'wh_customers'"); |
| 128 | + expect(draft.source).toContain('const wh_customers: ServiceObject = {'); |
| 129 | + }); |
| 130 | + |
| 131 | + it('does NOT double-prefix a remote table that already carries the namespace', async () => { |
| 132 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'wh_accounts'); |
| 133 | + |
| 134 | + expect(draft.name).toBe('wh_accounts'); |
| 135 | + expect(draft.name).not.toContain('wh_wh_'); |
| 136 | + expect(validateObjectNamespacePrefix(draft.name, 'wh')).toBeNull(); |
| 137 | + }); |
| 138 | + |
| 139 | + it('keeps the LABEL derived from the short name — the prefix is addressing, not display', async () => { |
| 140 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 141 | + expect(draft.definition.label).toBe('Customers'); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe('defect 2 — the generated draft declares an explicit sharingModel', () => { |
| 146 | + it('emits the OWD the #9666 precedent settled on, and the spec accepts the VALUE', async () => { |
| 147 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 148 | + |
| 149 | + expect(draft.definition.sharingModel).toBe('private'); |
| 150 | + // Not just "some string": a value the canonical enum still declares. If |
| 151 | + // ADR-0090's four ever change, this reddens instead of drifting. |
| 152 | + expect(CANONICAL_OWD).toContain(draft.definition.sharingModel); |
| 153 | + |
| 154 | + // A value verdict needs a full parse, not an absence-of-unknown-keys check. |
| 155 | + const parsed = ObjectSchema.safeParse(draft.definition); |
| 156 | + expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true); |
| 157 | + }); |
| 158 | + |
| 159 | + it('renders the OWD into the committed source, with the reason attached', async () => { |
| 160 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 161 | + |
| 162 | + expect(draft.source).toContain("sharingModel: 'private'"); |
| 163 | + expect(draft.source).toContain('security-owd-unset'); |
| 164 | + }); |
| 165 | + |
| 166 | + it('emits the OWD even when no namespace resolves — the two defects are independent', async () => { |
| 167 | + const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers'); |
| 168 | + expect(draft.definition.sharingModel).toBe('private'); |
| 169 | + expect(draft.source).toContain("sharingModel: 'private'"); |
| 170 | + }); |
| 171 | +}); |
| 172 | + |
| 173 | +describe('still-generates — the fix must not be satisfied by emitting a valid stub', () => { |
| 174 | + it('keeps every introspected column, its mapped type, and the review notes', async () => { |
| 175 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 176 | + const fields = draft.definition.fields as Record<string, { type: string }>; |
| 177 | + |
| 178 | + expect(Object.keys(fields)).toEqual(['id', 'name', 'signed_up_at']); |
| 179 | + expect(fields.id.type).toBe('text'); |
| 180 | + expect(fields.name.type).toBe('text'); |
| 181 | + expect(fields.signed_up_at.type).toBe('datetime'); |
| 182 | + |
| 183 | + expect(draft.source).toContain("id: { type: 'text' }"); |
| 184 | + expect(draft.source).toContain("signed_up_at: { type: 'datetime' }"); |
| 185 | + }); |
| 186 | + |
| 187 | + it('keeps the external binding pointed at the REMOTE table, not the renamed object', async () => { |
| 188 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 189 | + const external = draft.definition.external as { remoteName?: string; remoteSchema?: string }; |
| 190 | + |
| 191 | + // The object was renamed to `wh_customers`; the remote table was not. |
| 192 | + expect(external.remoteName).toBe('customers'); |
| 193 | + expect(external.remoteSchema).toBe('mart'); |
| 194 | + expect(draft.definition.datasource).toBe('warehouse'); |
| 195 | + expect(draft.source).toContain("remoteSchema: 'mart', remoteName: 'customers'"); |
| 196 | + }); |
| 197 | +}); |
| 198 | + |
| 199 | +describe('an absent or blank namespace must not trade one invalid draft for another', () => { |
| 200 | + /** |
| 201 | + * `_customers` is the failure mode this block exists to forbid: a name that |
| 202 | + * satisfies "has a prefix" while being neither valid nor meaningful. The |
| 203 | + * bare name is the defensible outcome — `defineStack()` skips the prefix |
| 204 | + * check entirely for a stack with no `manifest.namespace`, and deliberately |
| 205 | + * does not invent one on the author's behalf, so the draft stays committable |
| 206 | + * exactly where an unprefixed name is legal. |
| 207 | + */ |
| 208 | + it.each([ |
| 209 | + ['no resolver wired at all', undefined, false], |
| 210 | + ['a resolver that finds nothing', undefined, true], |
| 211 | + ['an empty string', '', true], |
| 212 | + ['whitespace only', ' ', true], |
| 213 | + ])('%s → the bare name, never a leading underscore', async (_label, ns, wired) => { |
| 214 | + const draft = await serviceWith(ns as string | undefined, { |
| 215 | + wireResolver: wired as boolean, |
| 216 | + }).generateObjectDraft('warehouse', 'customers'); |
| 217 | + |
| 218 | + expect(draft.name).toBe('customers'); |
| 219 | + expect(draft.name.startsWith('_')).toBe(false); |
| 220 | + expect(draft.source).not.toContain('_customers:'); |
| 221 | + expect(ObjectSchema.safeParse(draft.definition).success).toBe(true); |
| 222 | + }); |
| 223 | + |
| 224 | + it('says so loudly in the rendered file rather than failing silently', async () => { |
| 225 | + const draft = await serviceWith(undefined).generateObjectDraft('warehouse', 'customers'); |
| 226 | + |
| 227 | + expect(draft.source).toContain('TODO(namespace)'); |
| 228 | + expect(draft.source).toContain('ADR-0028'); |
| 229 | + }); |
| 230 | + |
| 231 | + it('carries NO namespace TODO once a namespace did resolve', async () => { |
| 232 | + const draft = await serviceWith('wh').generateObjectDraft('warehouse', 'customers'); |
| 233 | + expect(draft.source).not.toContain('TODO(namespace)'); |
| 234 | + }); |
| 235 | +}); |
| 236 | + |
| 237 | +describe('importObject inherits both repairs from the draft pipeline', () => { |
| 238 | + it('persists the prefixed name and the explicit OWD', async () => { |
| 239 | + const persisted: Array<{ name: string; def: Record<string, unknown> }> = []; |
| 240 | + const svc = new ExternalDatasourceService({ |
| 241 | + introspect: async () => remoteSchema(), |
| 242 | + getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }), |
| 243 | + getObject: async () => undefined, |
| 244 | + listObjects: async () => [], |
| 245 | + getNamespace: () => 'wh', |
| 246 | + persistObject: async (name, def) => { |
| 247 | + persisted.push({ name, def }); |
| 248 | + }, |
| 249 | + }); |
| 250 | + |
| 251 | + const result = await svc.importObject('warehouse', 'customers'); |
| 252 | + |
| 253 | + expect(result.name).toBe('wh_customers'); |
| 254 | + expect(persisted[0]?.name).toBe('wh_customers'); |
| 255 | + expect(persisted[0]?.def.sharingModel).toBe('private'); |
| 256 | + expect(ObjectSchema.safeParse(persisted[0]?.def).success).toBe(true); |
| 257 | + }); |
| 258 | +}); |
0 commit comments