diff --git a/.changeset/memory-persistence-placeholder-refused.md b/.changeset/memory-persistence-placeholder-refused.md new file mode 100644 index 0000000000..855814b500 --- /dev/null +++ b/.changeset/memory-persistence-placeholder-refused.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). + +The #8336 defect one surface over: a `${…}` placeholder written in the memory +driver's persistence config (e.g. `persistence: { type: 'file', path: +'${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create +and write a literal `./${DATA_DIR}/…` path, or write under the literal +placeholder-bearing localStorage key, with no error naming the unresolved +placeholder. #8336's ruling (refuse loudly at authoring time — the value was +authored under a false belief) applies to these two keys with its reason +intact: they are config-material like the connection keys, not record data. + +**What is refused:** a complete `${…}` span in memory `persistence.path` (file +persistence and the `auto` override) or `persistence.key` (localStorage and the +`auto` override) — the same shared judgment (`placeholderFree`) the +connection-material keys use, so the policy cannot drift per key. + +**What stays accepted:** every literal path/key byte-identically, including +placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the +memory driver's `initialData` stays deliberately **unjudged**: it carries +arbitrary record values, where a literal `${…}` may be legitimate data (the +mother ruling's deliberate memory-driver exclusion, which reached exactly as +far as its reason did). + +## FROM → TO + +```ts +// before — parsed green; the driver created a literal `./${DATA_DIR}/…` path +defineDatasource({ + name: 'scratch', driver: 'memory', + config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } }, +}) + +// after — write the literal path (or leave it unset: the shared datasource +// factory scopes the default destination per datasource) +defineDatasource({ + name: 'scratch', driver: 'memory', + config: { persistence: { type: 'file', path: './data/scratch.json' } }, +}) +``` + +There is deliberately **no automatic rewrite**: the placeholder names a value +that exists only in the author's intended deployment environment, which a +source-file transform cannot know. `os migrate meta` surfaces the change as a +structured TODO (semantic entry `memory-persistence-placeholder-refused`, +protocol major 18 — this refusal is not part of the v17.0.0 cut). + + diff --git a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts index b9a765e881..5cb926f468 100644 --- a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts @@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest'; import { DatasourceSchema } from '../datasource.zod'; import { containsUnresolvedPlaceholder } from './common.zod'; +import { MemoryConfigSchema } from './memory.zod'; import { MongoConfigSchema } from './mongo.zod'; import { MysqlConfigSchema } from './mysql.zod'; import { PostgresConfigSchema } from './postgres.zod'; @@ -170,6 +171,101 @@ describe('mongo `options` passthrough — the deep judgement (#8336)', () => { }); }); +/** + * #8495 — the #8336 shape one surface over: memory `persistence.path` (file + * persistence and the `auto` override) and `persistence.key` (localStorage) + * are config-material, not record data. A `${DATA_DIR}` written there is + * resolved by nothing — the driver would create and write a literal + * `./${DATA_DIR}/…` path (or a literal localStorage key), the same + * authored-under-a-false-belief defect. Inherited parent adjudication from + * #8336; `initialData` stays deliberately UNJUDGED (record values, where a + * literal `${…}` may be legitimate data) and is pinned so below. + */ +const MEMORY_PERSISTENCE_FAMILY = [ + { name: 'memory file persistence.path', key: 'persistence.path', + valid: (v: string) => ({ persistence: { type: 'file', path: v } }), + literal: './data/memory-driver.json', placeholder: '${DATA_DIR}/memory-driver.json' }, + { name: 'memory localStorage persistence.key', key: 'persistence.key', + valid: (v: string) => ({ persistence: { type: 'local', key: v } }), + literal: 'myapp:db', placeholder: 'myapp:${TENANT}:db' }, + { name: 'memory auto persistence.path override', key: 'persistence.path', + valid: (v: string) => ({ persistence: { type: 'auto', path: v } }), + literal: '/var/data/memory.json', placeholder: '${DATA_DIR}/memory.json' }, + { name: 'memory auto persistence.key override', key: 'persistence.key', + valid: (v: string) => ({ persistence: { type: 'auto', key: v } }), + literal: 'objectstack:memory-db', placeholder: '${STORAGE_KEY}' }, +] as const; + +describe.each(MEMORY_PERSISTENCE_FAMILY)('$name — unresolved placeholder refusal (#8495)', (f) => { + it('refuses a `${…}` placeholder, pathed under `persistence`, naming the key and the defect', () => { + const result = MemoryConfigSchema.safeParse(f.valid(f.placeholder)); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === f.key); + expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${f.key}\``); + // The ruling's guidance, verbatim: the non-capability is explicit. + expect(issue!.message).toContain('placeholders are not resolved here'); + // The measured defect, in the family's shared phrasing (#8078). + expect(issue!.message).toContain('resolved by nothing'); + // The prescription: the literal value. + expect(issue!.message).toContain('Write the literal value instead'); + }); + + it('accepts the literal spelling byte-identically (pin)', () => { + const config = f.valid(f.literal); + const before = MemoryConfigSchema.safeParse(config); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + expect(MemoryConfigSchema.parse(config)).toEqual(before.data); + }); + + it('placeholder-LOOKING literals stay accepted: `$VAR`, `{name}`, unclosed `${` are not the measured convention', () => { + for (const nearMiss of [ + f.literal + '$SUFFIX', + f.literal + '{curly}', + f.literal + '-${unclosed', + ]) { + const result = MemoryConfigSchema.safeParse(f.valid(nearMiss)); + expect(result.success, `\`${nearMiss}\` must stay accepted: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); +}); + +describe('memory `initialData` stays UNJUDGED — the deliberate #8336 exclusion holds (#8495)', () => { + it('a literal `${…}` in a record value is legitimate DATA and keeps parsing', () => { + // The mother ruling's memory-driver exclusion was argued from exactly this: + // `initialData` carries arbitrary record values, where `${…}` may be the + // real payload (a template string a downstream renderer consumes). The + // #8495 refusal covers `persistence.path`/`persistence.key` ONLY. + const config = { + initialData: { + templates: [{ id: '1', body: 'Hello ${name}, your order ${order_id} shipped.' }], + users: [{ id: '${weird-but-legal}', name: 'Alice' }], + }, + persistence: { type: 'file', path: './data/memory.json' }, + }; + const result = MemoryConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + // Byte-identical: the values are data, and data is never rewritten. + expect(result.data!.initialData).toEqual(config.initialData); + }); +}); + +describe('DatasourceSchema — the memory refusal reaches the authored artefact (#8495)', () => { + it('re-paths the refusal under `config.persistence.path` for the author', () => { + const result = DatasourceSchema.safeParse({ + name: 'scratch', + driver: 'memory', + config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.persistence.path'); + expect(issue, 'issue must be re-pathed under config.persistence.path').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('placeholders are not resolved here'); + }); +}); + describe('DatasourceSchema — the refusal reaches the authored artefact (#8336)', () => { it('re-paths the refusal under `config.` for the author', () => { const result = DatasourceSchema.safeParse({ diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 5436a45d8b..04a1cfdeea 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -6,6 +6,7 @@ import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { driverConfigJsonSchema, + placeholderFree, READ_ONLY_BELONGS_ON_DATASOURCE, SCHEMA_MODE_BELONGS_ON_DATASOURCE, } from './common.zod'; @@ -96,8 +97,17 @@ export const FilePersistenceConfigSchema = lazySchema(() => strictObject( }, { type: z.literal('file'), - /** File path to persist data (JSON format). Defaults to `.objectstack/data/memory-driver.json`. */ - path: z.string().optional().describe('File path to persist data'), + /** + * File path to persist data (JSON format). Defaults to `.objectstack/data/memory-driver.json`. + * + * `${…}` placeholder syntax is refused (#8495, the #8336 shape one surface + * over): nothing resolves it, so the driver would create and write a + * literal `./${DATA_DIR}/…` path — authored under a false belief. The + * memory driver's `initialData` stays deliberately unjudged (record + * values, where a literal `${…}` may be legitimate data); this key is + * config-material, not data. + */ + path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path to persist data'), /** Auto-save interval in milliseconds. Default: 2000ms. */ autoSaveInterval: z.number().min(100).default(2000).describe('Auto-save interval in ms'), }, @@ -118,8 +128,13 @@ export const LocalStoragePersistenceConfigSchema = lazySchema(() => strictObject }, { type: z.literal('local'), - /** localStorage key. Defaults to `objectstack:memory-db`. */ - key: z.string().optional().describe('localStorage key for persisted data'), + /** + * localStorage key. Defaults to `objectstack:memory-db`. + * + * `${…}` placeholder syntax is refused (#8495): nothing resolves it, so + * the driver would write under the literal placeholder-bearing key. + */ + key: placeholderFree(z.string(), 'persistence.key').optional().describe('localStorage key for persisted data'), }, ).describe('localStorage persistence configuration')); @@ -164,12 +179,20 @@ export const AutoPersistenceConfigSchema = lazySchema(() => strictObject( }, { type: z.literal('auto'), - /** File path override when running in Node.js. */ - path: z.string().optional().describe('File path override for Node.js environments'), + /** + * File path override when running in Node.js. + * `${…}` placeholder syntax is refused (#8495) — same judgment as the + * `file` branch's `path`; the auto-detected file adapter resolves nothing. + */ + path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path override for Node.js environments'), /** Auto-save interval override when running in Node.js. */ autoSaveInterval: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments'), - /** localStorage key override when running in a browser. */ - key: z.string().optional().describe('localStorage key override for browser environments'), + /** + * localStorage key override when running in a browser. + * `${…}` placeholder syntax is refused (#8495) — same judgment as the + * `local` branch's `key`. + */ + key: placeholderFree(z.string(), 'persistence.key').optional().describe('localStorage key override for browser environments'), }, ).describe('Auto-detect persistence configuration')); diff --git a/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts new file mode 100644 index 0000000000..93a8dc6962 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'memory-persistence-placeholder-refused', + surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + + 'override) and `persistence.key` (localStorage and the `auto` override) — values ' + + 'containing `${…}` placeholder syntax', + replacement: 'the literal path or key. For environment-specific destinations, leave the ' + + 'key unset and let the shared datasource factory scope the default per datasource, or ' + + 'compute the config value in code before it enters `defineStack`', + reason: + 'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' + + 'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' + + 'path, or write under the literal placeholder-bearing localStorage key, so the dump ' + + 'lands in a wrongly-named location with no error naming the unresolved placeholder ' + + '(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' + + 'are config-material like the connection keys, so the parent adjudication applies with ' + + 'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' + + 'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' + + 'is no mechanical rewrite: the placeholder names a value that exists only in the ' + + 'author\'s intended deployment environment, which a source-file transform cannot know.', + acceptanceCriteria: + 'Every memory datasource parses with no `${…}` span in `persistence.path` or ' + + '`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' + + 'byte-identically.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index dfb108bedc..2b846b422f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4723,6 +4723,68 @@ const step17: MigrationStep = { ], }; +/** + * Protocol 18 step — accumulating, uncut. + * + * v17.0.0 was cut before these narrowings landed, so their migration + * prescriptions belong to the NEXT major: `composeMigrationChain` filters + * `m <= toMajor` (default `PROTOCOL_MAJOR`), so this step is inert for every + * default caller until the protocol major reaches 18. The enforcement itself + * ships earlier on the 17.x line (launch-window convention: accept-set + * narrowings ride minor releases); this step is where `migrate meta` users + * are told, at the major boundary where they look. + * + * Mechanical: none yet. Semantic: the memory-driver persistence placeholder + * refusal (#8495) — the #8336 parent adjudication applied to the two + * config-material memory keys its deliberate `initialData` exclusion never + * covered. + */ +const step18: MigrationStep = { + toMajor: 18, + rationale: + 'Protocol 18 extends the #8336 unresolved-placeholder refusal to the memory ' + + 'driver\'s config-material persistence keys: `persistence.path` (file persistence ' + + 'and the `auto` override) and `persistence.key` (localStorage and the `auto` ' + + 'override) refuse `${…}` placeholder syntax at publish (#8495). Nothing resolves a ' + + 'placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or ' + + 'write under the literal localStorage key — the same authored-under-a-false-belief ' + + 'shape, one surface over. The memory driver\'s `initialData` stays deliberately ' + + 'unjudged: it carries arbitrary record values, where a literal `${…}` may be ' + + 'legitimate data.', + conversionIds: [], + semantic: [ + // One file per entry under `entries/semantic/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + { + id: 'memory-persistence-placeholder-refused', + surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + + 'override) and `persistence.key` (localStorage and the `auto` override) — values ' + + 'containing `${…}` placeholder syntax', + replacement: 'the literal path or key. For environment-specific destinations, leave the ' + + 'key unset and let the shared datasource factory scope the default per datasource, or ' + + 'compute the config value in code before it enters `defineStack`', + reason: + 'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' + + 'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' + + 'path, or write under the literal placeholder-bearing localStorage key, so the dump ' + + 'lands in a wrongly-named location with no error naming the unresolved placeholder ' + + '(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' + + 'are config-material like the connection keys, so the parent adjudication applies with ' + + 'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' + + 'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' + + 'is no mechanical rewrite: the placeholder names a value that exists only in the ' + + 'author\'s intended deployment environment, which a source-file transform cannot know.', + acceptanceCriteria: + 'Every memory datasource parses with no `${…}` span in `persistence.path` or ' + + '`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' + + 'byte-identically.', + }, + // + ], +}; + /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, @@ -4732,6 +4794,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 15: step15, 16: step16, 17: step17, + 18: step18, }; /** The majors that have a step, ascending. */