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
55 changes: 55 additions & 0 deletions .changeset/memory-persistence-placeholder-refused.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- adr-0087: registered memory-persistence-placeholder-refused -->
96 changes: 96 additions & 0 deletions packages/spec/src/data/driver/driver-placeholder-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.<key>` for the author', () => {
const result = DatasourceSchema.safeParse({
Expand Down
39 changes: 31 additions & 8 deletions packages/spec/src/data/driver/memory.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
},
Expand All @@ -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'));

Expand Down Expand Up @@ -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'));

Expand Down
Original file line number Diff line number Diff line change
@@ -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.',
};
63 changes: 63 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// <os-generated semantic:18>
{
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.',
},
// </os-generated semantic:18>
],
};

/** All migration steps, keyed by the major they migrate into. */
export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
11: step11,
Expand All @@ -4732,6 +4794,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
15: step15,
16: step16,
17: step17,
18: step18,
};

/** The majors that have a step, ascending. */
Expand Down
Loading