From 38a467f491750cb44985b39aea485995fe061d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:53:35 +0000 Subject: [PATCH 1/7] docs(create-objectstack): the starter's AGENTS.md and README say a Studio change stays in its database and name os package export (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- .../src/templates/AGENTS.md | 24 +++++++++++++++++++ .../src/templates/blank/README.md | 20 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md index c7d05b059f7..71c0342fd58 100644 --- a/packages/create-objectstack/src/templates/AGENTS.md +++ b/packages/create-objectstack/src/templates/AGENTS.md @@ -80,6 +80,30 @@ under `src/` is never loaded, and `{{PM}} run validate` neither counts nor check what it holds, until its `index.ts` barrel is imported in `objectstack.config.ts` and handed to its stack key there. +## Change the app in `src/` — a Studio change stays in its database + +`src/` is what ships: `{{PM}} run build` and the `Dockerfile` compile it, and +nothing else, into `dist/objectstack.json`. Studio, in the Console that +`{{PM}} run dev` serves, never writes `src/`. A change saved there to an item +this app ships (a view, a dashboard, a report) is an item of the package +`.custom`, stored in that environment's database — +locally, `.objectstack/data/`. It is not in the build and does not reach a +deployment. + +- **Make every change to the app in `src/`**, never through Studio or the + metadata API of a running server: `src/` is what is validated, reviewed and + deployed. When a person tried something in Studio and wants it in the app, + make the same change in `src/`. +- **A customization that should stay one moves as a package.** With the dev + server running, `npx os package export` writes every package the database + holds that `src/` does not register, the `.custom` package among them, to + `objectstack-export/NN-ID/objectstack.json`. Commit that directory. After + the deployment is up, install each file into it, in the numbered order: + `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`. + +`{{PM}} run build` prints a notice when the local dev database holds such a +customization. + ## AI Skills This project uses the ObjectStack skills catalog from the `@objectstack/skills` diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index b080b43b8c9..1569c1fe26c 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -153,6 +153,26 @@ curl -fsS http://localhost:8080/api/v1/health Bare Node, Kubernetes, reverse-proxy wiring, and the required secrets are covered in [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting). +### A Studio change is not in the image + +The image carries what `src/` builds into, and nothing else. A change you save +in Studio, in the Console `pnpm dev` serves, is stored in that environment's +database (locally, `.objectstack/data/`) as an item of the package +`.custom`. It is never written to `src/`, so +`pnpm build`, the `Dockerfile` and `docker compose` leave it behind. + +- To make it part of the app, make the same change in `src/`: that is what is + validated, reviewed and deployed. Ask your coding agent to do it in code, not + in Studio. +- To keep it as a customization, take it out as a package while `pnpm dev` + runs: `npx os package export` writes every package the database holds that + `src/` does not register, the `.custom` package among them, to + `objectstack-export/NN-ID/objectstack.json`. Commit that directory, and once + the deployment is up install each file into it in the numbered order with + `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`. + +`pnpm build` prints a notice when your local dev database holds such a change. + ## Next steps - Add an object: see the `objectstack-data` skill. From fa3cfe0baa5611c143a2f89c0b76221bb140b433 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:55:28 +0000 Subject: [PATCH 2/7] feat(cli): os build says when the local dev database holds Studio customizations it leaves behind (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- packages/cli/src/commands/build.ts | 3 + packages/cli/src/commands/compile.ts | 24 +++ .../cli/src/utils/local-customizations.ts | 190 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 packages/cli/src/utils/local-customizations.ts diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index df9d3de9452..0450340cbef 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -4,4 +4,7 @@ import Compile from './compile.js'; export default class Build extends Compile { static override description = 'Compile ObjectStack configuration to JSON artifact (alias for compile)'; + + /** `os build` is what a deployment runs, so it says what the artifact leaves behind. */ + protected override reportsLocalCustomizations = true; } diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 5fadf9a6d07..323cddda7a7 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -87,6 +87,7 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi // [#29051] An undeclared top-level flow cycle — the walk `os validate` step // 3a-vii runs, over the automation engine's own judge. import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js'; +import { localCustomizationNotice, readLocalCustomizations } from '../utils/local-customizations.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -121,6 +122,13 @@ export default class Compile extends Command { }), }; + /** + * Whether the text face ends with the local-customization notice + * (`utils/local-customizations.ts`). `os build` turns it on; `os compile`, + * which `os dev` runs on every boot and rebuild, keeps it off. + */ + protected reportsLocalCustomizations = false; + async run(): Promise { const { args, flags } = await this.parse(Compile); const timer = createTimer(); @@ -1447,6 +1455,22 @@ export default class Compile extends Command { } console.log(''); + // A Studio customization in the local dev database is left behind by + // this artifact (ADR-0146 D2: it lives in `.custom`, in the + // database). Said once, on `os build` only — the command a deployment + // runs; `os dev` compiles through `os compile` on every boot and rebuild, + // where it would be noise. + if (this.reportsLocalCustomizations) { + const notice = localCustomizationNotice( + await readLocalCustomizations({ projectRoot: process.cwd(), artifactPath }), + ); + if (notice.length > 0) { + printWarning(notice[0]); + for (const line of notice.slice(1)) console.log(chalk.dim(` ${line}`)); + console.log(''); + } + } + } catch (error: any) { if (isExitSignal(error)) throw error; if (flags.json) { diff --git a/packages/cli/src/utils/local-customizations.ts b/packages/cli/src/utils/local-customizations.ts new file mode 100644 index 00000000000..d7e20ab0068 --- /dev/null +++ b/packages/cli/src/utils/local-customizations.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * "Does the local dev database hold a Studio customization that this build + * leaves behind?" — the question `os build` asks after it writes the artifact. + * + * A change saved in Studio to an item a package ships is an item of that + * base's paired customization package `.custom` (ADR-0146 D2, D3), + * stored in the environment's database and never in `src/`. `os build` and + * the scaffold's `Dockerfile` compile `src/` alone, so a developer who tuned a + * view on `os dev` and then deployed lost it with no word said. ADR-0146 D2's + * way to move it is the package export (`os package export`), committed and + * installed on the target; this module only lets the build say so, once, at + * the moment the change is left behind. + * + * ## Cheap, local, read-only + * + * No server door and no network round trip: the build reads the SQLite file + * `os dev` would open — resolved by the ONE shared resolution + * (`resolveProjectDatabaseUrl`, `@objectstack/runtime`), over the environment + * `os dev` reads (this process's, with the project's development `.env*` files + * under it, as dotenv-flow layers them, and without loading them into this + * process) — opened read-only, one aggregate `SELECT` over `sys_metadata`. + * A database that is not a local SQLite file (Postgres, MongoDB, libSQL, a + * `:memory:` URL) is not probed: reading it would be a network round trip, so + * the answer is `not_applicable` and the build prints nothing. + * + * ## What counts + * + * Every `sys_metadata` row whose `package_id` ends in the pair suffix + * (`CUSTOMIZATION_PACKAGE_SUFFIX`), counted per package. Those rows are what + * `os package export` carries in a pair's export; an ensured pair with no item + * yet has no row here and nothing to lose, so it is not counted. + * + * ## Never a verdict it did not reach + * + * A file that cannot be read, a missing `better-sqlite3` (the optional native + * driver), or a database with no `sys_metadata` table yet are told apart: + * the first two answer `unknown` with the reason, the last `none` (a database + * `os dev` never booted holds no customization). The build never fails on + * this probe; `unknown` is printed as such, never as "nothing to export". + */ + +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import dotenvFlow from 'dotenv-flow'; +import { CUSTOMIZATION_PACKAGE_SUFFIX } from '@objectstack/spec/kernel'; + +/** One pair package and how many stored items it holds. */ +export interface LocalCustomizationPackage { + id: string; + items: number; +} + +export type LocalCustomizationReading = + /** No local SQLite file to read: a remote or in-memory database, or none created yet. */ + | { status: 'not_applicable' } + /** Read: no stored item of any `.custom` package. */ + | { status: 'none'; filename: string } + /** Read: these `.custom` packages hold stored items, which the build does not carry. */ + | { status: 'found'; filename: string; packages: LocalCustomizationPackage[] } + /** The file exists but could not be read; `detail` says why. */ + | { status: 'unknown'; filename: string; detail: string }; + +/** + * The environment `os dev` resolves its database from: this process's + * variables over the project's development `.env*` files (dotenv-flow never + * overrides a variable the process already has). Nothing is loaded into + * `process.env`. + */ +export function devDatabaseEnv( + projectRoot: string, + processEnv: Record = process.env, +): Record { + let fromFiles: Record = {}; + try { + const files = dotenvFlow.listFiles({ node_env: 'development', path: projectRoot }); + if (files.length > 0) fromFiles = dotenvFlow.parse(files); + } catch { + // An unreadable `.env*` file: `os dev` loads with `silent: true` and boots + // without it, so the build resolves without it too. + } + return { ...fromFiles, ...processEnv }; +} + +/** The SQLite file a resolved database URL names, or `null` when it names none on disk. */ +export function localSqliteFile( + url: string, + sqliteFilenameFromUrl: (url: string, kind: 'sqlite' | 'sqlite-wasm') => string, +): string | null { + const kind = /^file:/i.test(url) ? 'sqlite' : /^wasm-sqlite:\/\//i.test(url) ? 'sqlite-wasm' : null; + if (!kind) return null; + let filename: string; + try { + filename = sqliteFilenameFromUrl(url, kind); + } catch { + return null; + } + if (!filename || filename === ':memory:' || filename.startsWith(':')) return null; + return filename; +} + +/** The aggregate read: stored items per `.custom` package. */ +export const LOCAL_CUSTOMIZATION_QUERY = + "SELECT package_id AS id, COUNT(*) AS items FROM sys_metadata WHERE package_id LIKE ? ESCAPE '\\' " + + 'GROUP BY package_id ORDER BY package_id'; + +/** `%.custom`, with LIKE's own wildcards in the suffix escaped. */ +function suffixPattern(): string { + return `%${CUSTOMIZATION_PACKAGE_SUFFIX.replace(/[\\%_]/g, (c) => `\\${c}`)}`; +} + +/** Read the stored `.custom` items of one SQLite file. */ +export async function readCustomizationsFromSqlite(filename: string): Promise { + if (!existsSync(filename)) return { status: 'not_applicable' }; + + let Database: any; + try { + Database = (await import('better-sqlite3')).default; + } catch (e: any) { + return { status: 'unknown', filename, detail: `better-sqlite3 is not loadable (${e?.message ?? e})` }; + } + + let db: any; + try { + db = new Database(filename, { readonly: true, fileMustExist: true }); + const table = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sys_metadata'").get(); + if (!table) return { status: 'none', filename }; + const rows = db.prepare(LOCAL_CUSTOMIZATION_QUERY).all(suffixPattern()) as Array<{ id: unknown; items: unknown }>; + const packages = rows + .filter((r) => typeof r.id === 'string' && r.id.endsWith(CUSTOMIZATION_PACKAGE_SUFFIX)) + .map((r) => ({ id: r.id as string, items: Number(r.items) })); + return packages.length > 0 ? { status: 'found', filename, packages } : { status: 'none', filename }; + } catch (e: any) { + return { status: 'unknown', filename, detail: e?.message ?? String(e) }; + } finally { + try { db?.close(); } catch { /* never opened */ } + } +} + +/** + * Resolve the database `os dev` opens for `projectRoot` and read its stored + * `.custom` items. Never throws. + */ +export async function readLocalCustomizations(opts: { + projectRoot: string; + artifactPath?: string; + env?: Record; +}): Promise { + let filename: string | null; + try { + const { resolveProjectDatabaseUrl, sqliteFilenameFromUrl } = await import('@objectstack/runtime'); + const { url } = resolveProjectDatabaseUrl({ + env: opts.env ?? devDatabaseEnv(opts.projectRoot), + projectRoot: opts.projectRoot, + artifactPath: opts.artifactPath, + }); + filename = localSqliteFile(url, sqliteFilenameFromUrl); + } catch { + // A database selection the dev boot itself refuses: that boot reports it; + // there is no file here to read. + return { status: 'not_applicable' }; + } + if (!filename) return { status: 'not_applicable' }; + return readCustomizationsFromSqlite(path.resolve(filename)); +} + +/** + * The notice lines for a reading, or `[]` when there is nothing to say. Said + * once per build: what is left behind, where it lives, and the one command + * that takes it out. + */ +export function localCustomizationNotice(reading: LocalCustomizationReading): string[] { + if (reading.status === 'found') { + const total = reading.packages.reduce((n, p) => n + p.items, 0); + const list = reading.packages.map((p) => `${p.id} (${p.items})`).join(', '); + return [ + `This build does not carry ${total} Studio customization${total === 1 ? '' : 's'} stored in your dev database: ${list}.`, + `They live in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have them.`, + 'Make the change in src/ to ship it with the app, or take it out as a package while `os dev` runs: `os package export`.', + ]; + } + if (reading.status === 'unknown') { + return [ + `Could not check ${reading.filename} for Studio customizations this build leaves behind (${reading.detail}).`, + 'A change saved in Studio is never in src/; `os package export` takes it out while `os dev` runs.', + ]; + } + return []; +} From 4e4d98add75d5402dd7208288a5216de4d33c522 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:56:27 +0000 Subject: [PATCH 3/7] test(cli): pin the local-customization read and notice (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- .../utils/local-customizations.sqlite.test.ts | 94 +++++++++++++++ .../src/utils/local-customizations.test.ts | 111 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 packages/cli/src/utils/local-customizations.sqlite.test.ts create mode 100644 packages/cli/src/utils/local-customizations.test.ts diff --git a/packages/cli/src/utils/local-customizations.sqlite.test.ts b/packages/cli/src/utils/local-customizations.sqlite.test.ts new file mode 100644 index 00000000000..379e5735b53 --- /dev/null +++ b/packages/cli/src/utils/local-customizations.sqlite.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The integration half of `os build`'s local-customization notice: the read +// against a real SQLite file, the shape `os dev` leaves in +// `.objectstack/data/objectstack.db` (a `sys_metadata` row per stored item, +// keyed by `package_id`). The pure half is `local-customizations.test.ts`. + +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { readCustomizationsFromSqlite, readLocalCustomizations } from './local-customizations.js'; + +const dirs: string[] = []; +function tempProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-db-')); + dirs.push(dir); + return dir; +} +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +function devDatabase(root: string, rows: Array<[type: string, name: string, packageId: string | null]>): string { + const file = path.join(root, '.objectstack', 'data', 'objectstack.db'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new Database(file); + db.exec('CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, package_id TEXT, state TEXT)'); + const insert = db.prepare('INSERT INTO sys_metadata (id, type, name, package_id, state) VALUES (?, ?, ?, ?, ?)'); + rows.forEach(([type, name, packageId], i) => insert.run(`m${i}`, type, name, packageId, 'active')); + db.close(); + return file; +} + +describe('reading the stored `.custom` items of the dev database', () => { + it('counts the items of each `.custom` package, and nothing else', async () => { + const root = tempProject(); + const file = devDatabase(root, [ + ['view', 'asset_list', 'com.example.asset-repair.custom'], + ['dashboard', 'ops', 'com.example.asset-repair.custom'], + ['view', 'lead_list', 'com.vendor.crm.custom'], + ['view', 'authored', 'com.example.studio-app'], + ['view', 'packageless', null], + // A package whose id merely contains the suffix is not a pair. + ['view', 'decoy', 'com.custom.tools'], + // LIKE's `_` must not stand in for the suffix's dot. + ['view', 'decoy2', 'com.example_custom'], + ]); + + const reading = await readLocalCustomizations({ projectRoot: root, env: {} }); + expect(reading).toEqual({ + status: 'found', + filename: file, + packages: [ + { id: 'com.example.asset-repair.custom', items: 2 }, + { id: 'com.vendor.crm.custom', items: 1 }, + ], + }); + }); + + it('answers none for a database with no `.custom` item, or not yet booted into tables', async () => { + const root = tempProject(); + const file = devDatabase(root, [['view', 'authored', 'com.example.studio-app']]); + expect(await readCustomizationsFromSqlite(file)).toEqual({ status: 'none', filename: file }); + + const empty = path.join(tempProject(), 'empty.db'); + new Database(empty).close(); + expect(await readCustomizationsFromSqlite(empty)).toEqual({ status: 'none', filename: empty }); + }); + + it('opens the file read-only: it is byte-identical after the read', async () => { + const root = tempProject(); + const file = devDatabase(root, [['view', 'asset_list', 'com.example.app.custom']]); + const before = fs.readFileSync(file); + await readCustomizationsFromSqlite(file); + expect(fs.readFileSync(file).equals(before)).toBe(true); + }); + + it('answers unknown, with the reason, for a file that is not a database', async () => { + const file = path.join(tempProject(), 'objectstack.db'); + fs.writeFileSync(file, 'not a sqlite database, just text that is long enough to have a header'.repeat(10)); + const reading = await readCustomizationsFromSqlite(file); + expect(reading.status).toBe('unknown'); + expect(reading).toMatchObject({ filename: file }); + }); + + it('follows OS_DATABASE_URL to the file `os dev` would open', async () => { + const root = tempProject(); + const elsewhere = devDatabase(tempProject(), [['view', 'v', 'com.example.app.custom']]); + const reading = await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: `file:${elsewhere}` } }); + expect(reading).toMatchObject({ status: 'found', filename: elsewhere }); + }); +}); diff --git a/packages/cli/src/utils/local-customizations.test.ts b/packages/cli/src/utils/local-customizations.test.ts new file mode 100644 index 00000000000..6daad1ff662 --- /dev/null +++ b/packages/cli/src/utils/local-customizations.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The pure half of `os build`'s local-customization notice: which database it +// reads, which file a URL names, what it says for each reading, and that only +// `os build` says it. Reading a real SQLite file is the integration half +// (`local-customizations.sqlite.test.ts`). + +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { sqliteFilenameFromUrl } from '@objectstack/runtime'; +import { + devDatabaseEnv, + localSqliteFile, + localCustomizationNotice, + readCustomizationsFromSqlite, + readLocalCustomizations, +} from './local-customizations.js'; +import Build from '../commands/build.js'; +import Compile from '../commands/compile.js'; + +const dirs: string[] = []; +function tempProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-')); + dirs.push(dir); + return dir; +} +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +describe('the database the notice reads is the one `os dev` opens', () => { + it('layers the development .env files under the process environment, without loading them', () => { + const root = tempProject(); + fs.writeFileSync(path.join(root, '.env'), 'OS_DATABASE_URL=file:./from-env-file.db\nOS_ONLY_IN_FILE=1\n'); + fs.writeFileSync(path.join(root, '.env.development'), 'OS_DATABASE_URL=file:./from-development.db\n'); + + const fromFiles = devDatabaseEnv(root, {}); + expect(fromFiles.OS_DATABASE_URL).toBe('file:./from-development.db'); + expect(fromFiles.OS_ONLY_IN_FILE).toBe('1'); + + // dotenv-flow never overrides a variable the process already has. + expect(devDatabaseEnv(root, { OS_DATABASE_URL: 'file:/from-shell.db' }).OS_DATABASE_URL).toBe('file:/from-shell.db'); + expect(process.env.OS_ONLY_IN_FILE).toBeUndefined(); + }); + + it('names a file only for a local SQLite URL', () => { + expect(localSqliteFile('file:/tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db'); + expect(localSqliteFile('wasm-sqlite:///tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db'); + expect(localSqliteFile('file::memory:', sqliteFilenameFromUrl)).toBeNull(); + expect(localSqliteFile(':memory:', sqliteFilenameFromUrl)).toBeNull(); + expect(localSqliteFile('postgres://u:p@db:5432/app', sqliteFilenameFromUrl)).toBeNull(); + expect(localSqliteFile('libsql://app.turso.io', sqliteFilenameFromUrl)).toBeNull(); + }); + + it('reads nothing when the resolved database is remote or not created yet', async () => { + const root = tempProject(); + expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: 'postgres://u:p@db:5432/app' } })) + .toEqual({ status: 'not_applicable' }); + // The unified default `/.objectstack/data/objectstack.db`, never booted. + expect(await readLocalCustomizations({ projectRoot: root, env: {} })).toEqual({ status: 'not_applicable' }); + expect(await readCustomizationsFromSqlite(path.join(root, 'absent.db'))).toEqual({ status: 'not_applicable' }); + }); + + it('reads nothing, and never throws, for a selection the dev boot refuses', async () => { + const root = tempProject(); + expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_DRIVER: 'memory' } })) + .toEqual({ status: 'not_applicable' }); + }); +}); + +describe('what the notice says', () => { + it('names each pair, the count, the file and `os package export`', () => { + const lines = localCustomizationNotice({ + status: 'found', + filename: '/p/.objectstack/data/objectstack.db', + packages: [ + { id: 'com.example.app.custom', items: 2 }, + { id: 'com.vendor.crm.custom', items: 1 }, + ], + }); + const text = lines.join('\n'); + expect(text).toContain('3 Studio customizations'); + expect(text).toContain('com.example.app.custom (2)'); + expect(text).toContain('com.vendor.crm.custom (1)'); + expect(text).toContain('/p/.objectstack/data/objectstack.db'); + expect(text).toContain('os package export'); + }); + + it('says it could not check, never that there is nothing, when the read failed', () => { + const text = localCustomizationNotice({ status: 'unknown', filename: '/p/x.db', detail: 'file is not a database' }).join('\n'); + expect(text).toContain('Could not check /p/x.db'); + expect(text).toContain('file is not a database'); + expect(text).toContain('os package export'); + }); + + it('says nothing when there is nothing to leave behind', () => { + expect(localCustomizationNotice({ status: 'none', filename: '/p/x.db' })).toEqual([]); + expect(localCustomizationNotice({ status: 'not_applicable' })).toEqual([]); + }); +}); + +describe('only `os build` says it', () => { + it('turns the notice on for build and leaves it off for compile, which `os dev` runs', () => { + const flag = (C: typeof Compile) => (Reflect.construct(C, [[], {}]) as unknown as { reportsLocalCustomizations: boolean }) + .reportsLocalCustomizations; + expect(flag(Build)).toBe(true); + expect(flag(Compile)).toBe(false); + }); +}); From ea4edc0929b7e9b44c8962ea5105bdf7b018a6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:00:51 +0000 Subject: [PATCH 4/7] fix(cli,create-objectstack): singular notice text; the docs name os login before os package export (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- packages/cli/src/utils/local-customizations.sqlite.test.ts | 7 +++++-- packages/cli/src/utils/local-customizations.ts | 4 +++- packages/create-objectstack/src/templates/AGENTS.md | 3 ++- packages/create-objectstack/src/templates/blank/README.md | 3 ++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/local-customizations.sqlite.test.ts b/packages/cli/src/utils/local-customizations.sqlite.test.ts index 379e5735b53..dd520e2e398 100644 --- a/packages/cli/src/utils/local-customizations.sqlite.test.ts +++ b/packages/cli/src/utils/local-customizations.sqlite.test.ts @@ -10,6 +10,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; +// Loaded here, at module top, so no clocked test pays the runtime graph's first +// load inside its window: `readLocalCustomizations` imports it lazily. +import '@objectstack/runtime'; import { readCustomizationsFromSqlite, readLocalCustomizations } from './local-customizations.js'; const dirs: string[] = []; @@ -44,8 +47,8 @@ describe('reading the stored `.custom` items of the dev database', () => { ['view', 'packageless', null], // A package whose id merely contains the suffix is not a pair. ['view', 'decoy', 'com.custom.tools'], - // LIKE's `_` must not stand in for the suffix's dot. - ['view', 'decoy2', 'com.example_custom'], + // The suffix ends the id; `.customs` is another package. + ['view', 'decoy2', 'com.example.customs'], ]); const reading = await readLocalCustomizations({ projectRoot: root, env: {} }); diff --git a/packages/cli/src/utils/local-customizations.ts b/packages/cli/src/utils/local-customizations.ts index d7e20ab0068..dd652bd2e22 100644 --- a/packages/cli/src/utils/local-customizations.ts +++ b/packages/cli/src/utils/local-customizations.ts @@ -176,7 +176,9 @@ export function localCustomizationNotice(reading: LocalCustomizationReading): st const list = reading.packages.map((p) => `${p.id} (${p.items})`).join(', '); return [ `This build does not carry ${total} Studio customization${total === 1 ? '' : 's'} stored in your dev database: ${list}.`, - `They live in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have them.`, + total === 1 + ? `It lives in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have it.` + : `They live in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have them.`, 'Make the change in src/ to ship it with the app, or take it out as a package while `os dev` runs: `os package export`.', ]; } diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md index 71c0342fd58..6c4dcbb6c5b 100644 --- a/packages/create-objectstack/src/templates/AGENTS.md +++ b/packages/create-objectstack/src/templates/AGENTS.md @@ -95,7 +95,8 @@ deployment. deployed. When a person tried something in Studio and wants it in the app, make the same change in `src/`. - **A customization that should stay one moves as a package.** With the dev - server running, `npx os package export` writes every package the database + server running, `npx os login --url http://localhost:3000` once, then + `npx os package export --url http://localhost:3000`, writes every package the database holds that `src/` does not register, the `.custom` package among them, to `objectstack-export/NN-ID/objectstack.json`. Commit that directory. After the deployment is up, install each file into it, in the numbered order: diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 1569c1fe26c..a1519919dc1 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -165,7 +165,8 @@ database (locally, `.objectstack/data/`) as an item of the package validated, reviewed and deployed. Ask your coding agent to do it in code, not in Studio. - To keep it as a customization, take it out as a package while `pnpm dev` - runs: `npx os package export` writes every package the database holds that + runs: `npx os login --url http://localhost:3000` once, then + `npx os package export --url http://localhost:3000`, writes every package the database holds that `src/` does not register, the `.custom` package among them, to `objectstack-export/NN-ID/objectstack.json`. Commit that directory, and once the deployment is up install each file into it in the numbered order with From 1051118e1e38a4f34edd980c9107a62e37ef0056 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:01:24 +0000 Subject: [PATCH 5/7] test(create-objectstack): pin the Studio-customization disclosure; changeset Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- ...30119-studio-customization-build-notice.md | 12 ++++ .../studio-customization-disclosure.test.ts | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .changeset/30119-studio-customization-build-notice.md create mode 100644 packages/create-objectstack/src/studio-customization-disclosure.test.ts diff --git a/.changeset/30119-studio-customization-build-notice.md b/.changeset/30119-studio-customization-build-notice.md new file mode 100644 index 00000000000..4262b9e9af7 --- /dev/null +++ b/.changeset/30119-studio-customization-build-notice.md @@ -0,0 +1,12 @@ +--- +'@objectstack/cli': patch +'create-objectstack': patch +--- + +feat(cli,create-objectstack): `os build` says when the local dev database holds Studio customizations the artifact leaves behind; the starter's `AGENTS.md` and README say a Studio change lives in the database and name `os package export` + +Clause-②: no +Metadata-protocol: none + +- **`@objectstack/cli`: `os build`** ends, on its text face, with one notice when the SQLite database `os dev` opens for the project (the same resolution, over the project's development `.env*` files) holds items of a `.custom` package: how many, in which pair, the database file, and that `os package export` takes them out while `os dev` runs. A change saved in Studio is such an item (ADR-0146 D2), never part of `src/`, so neither the artifact nor the starter's `Dockerfile` carries it. The read is local and read-only; a database that is not a local SQLite file is not probed and prints nothing, and a file that cannot be read is said to be unchecked, never empty. `os compile`, which `os dev` runs on every boot and rebuild, and `os build --json` print nothing new. +- **`create-objectstack`: the generated `AGENTS.md` and the blank starter's README** say that Studio saves into the environment's database as an item of `.custom`, that `build` and the `Dockerfile` leave it behind, that app changes are made in `src/`, and how a customization moves as a package (`os login`, `os package export`, then `os package install` on the deployment, in the numbered order). diff --git a/packages/create-objectstack/src/studio-customization-disclosure.test.ts b/packages/create-objectstack/src/studio-customization-disclosure.test.ts new file mode 100644 index 00000000000..e9fbac32b66 --- /dev/null +++ b/packages/create-objectstack/src/studio-customization-disclosure.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A change saved in Studio to an item the app ships is an item of the app's +// paired `.custom` package, stored in the environment's database (ADR-0146 +// D2) — never in `src/`, so `build` and the Dockerfile leave it behind. The +// scaffold's two docs a developer and their coding agent read first must say +// so, name the command that takes it out (`os package export`), and tell the +// agent to make app changes in `src/`. Both are static files `index.ts` +// copies, so a source-text pin is what covers them. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'templates'); +const AGENTS_MD = fs.readFileSync(path.join(templatesDir, 'AGENTS.md'), 'utf8'); +const README = fs.readFileSync(path.join(templatesDir, 'blank', 'README.md'), 'utf8'); + +/** The section under the heading that matches `heading`, up to the next heading of that level or higher. */ +function section(markdown: string, heading: RegExp, level: number): string { + const lines = markdown.split('\n'); + const at = lines.findIndex((l) => heading.test(l)); + if (at === -1) return ''; + const stop = new RegExp(`^#{1,${level}} `); + const end = lines.findIndex((l, i) => i > at && stop.test(l)); + return lines.slice(at, end === -1 ? undefined : end).join('\n'); +} + +describe.each([ + ['the scaffold AGENTS.md', section(AGENTS_MD, /^## .*Studio/, 2)], + ['the blank README', section(README, /^### .*Studio/, 3)], +])('%s says a Studio change does not travel with the build', (_doc, text) => { + it('has the section (vacuity guard)', () => { + expect(text.length).toBeGreaterThan(200); + }); + + it('says where the change lives and that the build leaves it behind', () => { + expect(text).toContain('.custom'); + expect(text).toMatch(/database/); + expect(text).toContain('src/'); + expect(text).toContain('Dockerfile'); + }); + + it('names the command that takes it out, and the install on the target', () => { + expect(text).toContain('os package export'); + expect(text).toContain('os package install'); + }); +}); + +describe('the scaffold AGENTS.md tells the agent where app changes go', () => { + it('makes app changes in src/, not through Studio', () => { + const text = section(AGENTS_MD, /^## .*Studio/, 2); + expect(text).toMatch(/\*\*Make every change to the app in `src\/`\*\*, never through Studio/); + }); +}); From f939093caadbb305df427a81bc3f0fae17c50482 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:23:56 +0000 Subject: [PATCH 6/7] test(cli): classify the local-customization notice in the build/validate parity roster Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- packages/cli/test/validate-build-gate-parity.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 770ea80c9cc..4dd6d889aec 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -364,6 +364,14 @@ const NOT_A_GATE: Readonly> = { // classification, for the same reasons. 'Artifact assembly — puts already-collected, already-linted content into the bundle `os build` writes; judges nothing': ['placeCollectedDocs', 'placeSeedFiles', 'placeConnectorSpecFiles'], + // `os build`'s local-customization notice (`utils/local-customizations.ts`): + // it reads the local dev DATABASE, never the stack, and says how many + // Studio customizations (`.custom` rows) the artifact leaves behind. + // It refuses nothing and the exit status never depends on it, so it is no + // gate in either direction; `os validate` writes no artifact to leave + // anything behind. + 'An advisory about the local dev database, not the stack — says what the artifact leaves behind; judges no metadata and refuses nothing': + ['readLocalCustomizations', 'localCustomizationNotice'], 'Presentation — renders, formats or serialises a verdict something else reached; judges nothing': [ 'printHeader', From fd70306c18a3f467837edcf9e5c4c8f9e6fc0844 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:27:20 +0000 Subject: [PATCH 7/7] chore(changeset): declare the CompileCommand protected flag as a widening Claude-Session: https://claude.ai/code/session_01Utp6CbrroXG7sXSzmQs4uj Co-authored-by: Claude --- .changeset/30119-studio-customization-build-notice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30119-studio-customization-build-notice.md b/.changeset/30119-studio-customization-build-notice.md index 4262b9e9af7..4bbfd3ed11b 100644 --- a/.changeset/30119-studio-customization-build-notice.md +++ b/.changeset/30119-studio-customization-build-notice.md @@ -5,7 +5,7 @@ feat(cli,create-objectstack): `os build` says when the local dev database holds Studio customizations the artifact leaves behind; the starter's `AGENTS.md` and README say a Studio change lives in the database and name `os package export` -Clause-②: no +Clause-②: yes (widening) — the exported CompileCommand gains a protected reportsLocalCustomizations flag (default false); nothing removed or narrowed Metadata-protocol: none - **`@objectstack/cli`: `os build`** ends, on its text face, with one notice when the SQLite database `os dev` opens for the project (the same resolution, over the project's development `.env*` files) holds items of a `.custom` package: how many, in which pair, the database file, and that `os package export` takes them out while `os dev` runs. A change saved in Studio is such an item (ADR-0146 D2), never part of `src/`, so neither the artifact nor the starter's `Dockerfile` carries it. The read is local and read-only; a database that is not a local SQLite file is not probed and prints nothing, and a file that cannot be read is said to be unchecked, never empty. `os compile`, which `os dev` runs on every boot and rebuild, and `os build --json` print nothing new.