diff --git a/.changeset/cli-stale-dist-tests-and-project-root.md b/.changeset/cli-stale-dist-tests-and-project-root.md new file mode 100644 index 0000000000..1fd12e0b5e --- /dev/null +++ b/.changeset/cli-stale-dist-tests-and-project-root.md @@ -0,0 +1,42 @@ +--- +"@objectstack/runtime": patch +"@objectstack/cli": patch +--- + +fix(runtime,cli): `projectRoot` reaches the metadata repository; stop compiling tests into the CLI's dist (#4065) + +Two defects behind the last of #4065's stray `.objectstack/` directories — the +one under `packages/cli/`. Neither is cosmetic. + +**1. `projectRoot` only got half the stack.** `createStandaloneStack`'s +`projectRoot` is documented as scoping a boot's on-disk state to the project +folder "so different examples / apps don't share a single database by accident", +and it did redirect the default sqlite database. But it was never passed to +`MetadataPlugin`, whose `FileSystemRepository` kept rooting at `process.cwd()`. +So one "project root" meant two different directories: a boot pointed at project +A wrote `A/.objectstack/data/` and `/.objectstack/metadata/`. It now +forwards `rootDir`, and `bootSchemaStack` accepts a `projectRoot` to pass down +(defaulting to `process.cwd()`, which is right for every real `os migrate` — the +CLI runs from the project directory). The two migrate integration suites, which +build a fixture project in a tempdir, now scope their boots to it. + +**2. The CLI compiled its own tests into `dist/` — and vitest ran them.** +`tsconfig.build.json` included all of `src` with no exclude, so every +`src/**/*.test.ts` was emitted as `dist/**/*.test.js`. Two consequences: + +- `files: ["dist"]` **published** them. +- This package has no vitest config, so `vitest run` collected the compiled + copies alongside the sources: **81 test files and 849 tests where the sources + hold 58 and 581**. Every `src/` test also ran as a stale `dist/` twin built + from whatever the source said at the last build. + +That is not just noise — it silently defeats edits. A fix to a source test +appeared not to work, because the run was still executing the pre-fix compiled +duplicate; that is exactly how the `.objectstack` residue survived a correct +fix long enough to look like a different bug. It also means a source test could +be edited to pass while its stale twin kept asserting the old behaviour, and +neither would be obviously wrong. Test files are now excluded from the build. + +No other package is affected: the rest build with `tsup`, which emits only +declared entry points. Verified by scanning every `packages/*/dist` for +`*.test.js` — the CLI was the only hit. diff --git a/.changeset/memory-driver-opt-in-persistence.md b/.changeset/memory-driver-opt-in-persistence.md new file mode 100644 index 0000000000..3f2bf18a17 --- /dev/null +++ b/.changeset/memory-driver-opt-in-persistence.md @@ -0,0 +1,82 @@ +--- +"@objectstack/driver-memory": major +"@objectstack/spec": major +"@objectstack/plugin-dev": patch +--- + +fix(driver-memory,spec): persistence is opt-in again — `new InMemoryDriver()` is pure in-memory (#4065) + +`InMemoryDriverConfig.persistence` defaulted to `'auto'`, and in Node.js `'auto'` +means **file**. So a bare `new InMemoryDriver()` — the shape every caller in this +repo used — silently wrote `.objectstack/data/memory-driver.json` into the process +CWD and reloaded it on the next boot. The default is now `false`. + +**This restores the accepted design rather than replacing it.** #815, the issue +that introduced the persistence capability, specified it as opt-in in requirement +\#1 — "默认情况下不启用持久化(纯内存,行为不变)" — and listed +`new InMemoryDriver()` under "纯内存" in its own config examples. The `'auto'` +default was a drift from that spec. + +What let the drift survive is worth naming, because it is not "there was no +test". `MemoryConfigSchema` *did* pin the default, and asserted `'auto'`; the +driver honoured `'auto'`; so spec and implementation agreed, and the pair looked +verified. What nothing checked was whether the value they agreed on was the one +#815 accepted. The driver's own `persistence.test.ts` could not have caught it +either — every case there passes `persistence` explicitly, so the omitted-value +path was untested on the implementation side. Both sides are now covered: three +behavioural tests in `persistence.test.ts` (no CWD write, no cross-instance row +carry-over, opt-in still persists) and the flipped schema assertion. + +**The symptom this fixes.** `packages/runtime/src/datasource-autoconnect.test.ts` +seeds two rows with fixed ids and asserts the exact set. Run 1 passed and wrote +the rows to disk; run 2 loaded them back, appended two more, and failed with four +rows; run N had 2N. CI never saw it — every job is a fresh clone, so every CI run +is run 1 — but `pnpm test` twice in one working tree could only ever go green +once. The persisted file's `created_at` values, one pair per run, were the proof. + +(#4083 fixed that particular suite from the factory side, and its regression +test is kept as-is. The blast radius was wider than one suite, though: **every** +bare `new InMemoryDriver()` inherited the default, so any code path constructing +one directly wrote to its working directory. Unit tests should not have write +side effects on the CWD at all.) + +**Migrating.** Callers that want durability now ask for it: + +```ts +new InMemoryDriver() // pure in-memory (new default) +new InMemoryDriver({ persistence: 'file' }) // Node.js, durable across restarts +new InMemoryDriver({ persistence: 'local' }) // browser, durable across reloads +new InMemoryDriver({ persistence: 'auto' }) // previous default behaviour +``` + +The `'auto'` / `'file'` / `'local'` / custom-adapter paths are unchanged; only +the value used when `persistence` is omitted moved. + +**Relationship to #4083.** That issue fixed the same hazard one consumer at a +time, and landed first: `createDefaultDatasourceDriverFactory` now passes +`persistence: false` for a declared `{ driver: 'memory' }` datasource and scopes +an opted-in destination *per datasource*, and the dev sqlite step-down's +last-resort rung passes `false` too. Both are kept exactly as #4083 wrote them. +This change closes the half they deliberately left open — a directly-constructed +`new InMemoryDriver()` — which is the path that still wrote into the working +directory of whatever process happened to build one. + +The two are complementary, not redundant. #4083's per-datasource scoping is +still the only thing that expands `'auto'`/`'file'`/`'local'` into a destination +carrying the datasource name, so two pools that DO opt in never alias one file; +its explicit `false` becomes belt-and-braces, which is the right posture for a +path that must never persist. + +`DevPlugin`'s driver is now explicitly `persistence: false`, matching the cache, +queue, job, i18n, storage and search stubs it ships beside — it was the one piece +of that stack that quietly outlived the process. + +**One claim trimmed, no behaviour attached.** The class docstring called this a +"production-ready implementation of the ObjectStack Driver Protocol". It stores +no constraints at all — `create()` is a `table.push()` and `syncSchema()` only +allocates an array — so there is no primary key, uniqueness, `NOT NULL`, foreign +key or column typing, and `bulkCreate` lands duplicate ids where a SQL driver +raises a violation (the second finding in #4065). The docstring now says so, and +points test authors at in-memory SQLite. Per Prime Directive #10 the fix for +`declared ≠ enforced` is to implement it, trim the claim, or file it; with this +driver moving to maintenance-only the claim is what goes. diff --git a/.changeset/scaffolds-drop-memory-driver.md b/.changeset/scaffolds-drop-memory-driver.md new file mode 100644 index 0000000000..e418b84a13 --- /dev/null +++ b/.changeset/scaffolds-drop-memory-driver.md @@ -0,0 +1,28 @@ +--- +"@objectstack/cli": patch +"create-objectstack": patch +--- + +chore(cli,create-objectstack): scaffolds no longer name a driver (#4065) + +`os init` and the `create-objectstack` blank template both listed +`@objectstack/driver-memory` in the generated `dependencies`. It was the only +driver named, which read as an endorsement — "this is the driver your app runs +on" — when it is in fact the **last-resort rung** of the dev step-down (native +`better-sqlite3` → WASM SQLite → mingo). A new project's first impression of the +data layer should not be the engine that enforces no primary keys, no +uniqueness, no `NOT NULL` and no column types. + +It was also redundant: `@objectstack/runtime` already depends on `driver-sql`, +`driver-sqlite-wasm` and `driver-memory`, and every script in both scaffolds runs +through the CLI, which carries all four. Removing the line changes nothing a +generated project can do — `objectstack dev` still resolves SQLite by default, +and `OS_DATABASE_URL` still selects Postgres / MySQL / MongoDB. + +Docs updated to match: the "packages you depend on" table in *Your first project* +no longer lists a driver row (it now says where drivers come from), and the +Memory Driver section of *Database Drivers* documents the opt-in persistence +default, carries a migration callout for the old `'auto'` behaviour, and points +test authors at in-memory SQLite. That section also claimed "Data is lost when +the process exits", which was simply false while `'auto'` was the default — it +wrote a file into the working directory. diff --git a/.changeset/tests-off-memory-driver.md b/.changeset/tests-off-memory-driver.md new file mode 100644 index 0000000000..6d02f333a9 --- /dev/null +++ b/.changeset/tests-off-memory-driver.md @@ -0,0 +1,50 @@ +--- +"@objectstack/runtime": patch +"@objectstack/client": patch +"@objectstack/metadata": patch +--- + +test(runtime,client,metadata): back the remaining suites with in-memory SQLite instead of the mingo driver (#4065) + +Ten test files used `InMemoryDriver` as a convenience backing store — somewhere +for rows to go while the suite proved something else (REST routing, datasource +auto-connect, the batch `$ref` contract, metadata history). They now run on +`SqliteWasmDriver` at `:memory:`, the same engine `@objectstack/verify`'s +`bootStack` already gives the dogfood gate: pure JS (no native build, CI-safe on +any runner) and real SQL semantics. + +The point is fidelity, not tidiness. Production runs SQL, and mingo differs from +it in ways that let a suite pass while the behaviour it stands for is broken. +Every failure this migration produced was a fixture defect the memory driver had +been absorbing: + +- **Tables were never created.** `driver.create()` on the memory driver is a + bare `table.push()` onto an auto-vivified array, so an object registered + *after* `kernel.bootstrap()` — which misses the boot-time schema sync — looked + fine. On SQL the first write fails with `no such table`, which the REST error + mapper turns into a **404 `OBJECT_NOT_FOUND`**: a routing-shaped symptom for a + DDL-shaped cause. Four suites needed an explicit `syncObjectSchema`. +- **A missing object declaration read as working.** `notifications.hono.integration` + writes `sys_notification`, which `MessagingServicePlugin` does not declare — + it is a platform object, and that lean kernel never booted `platform-objects`. + Auto-vivification hid the omission entirely. The suite now registers the real + `SysNotification` rather than a hand-copied stand-in, so there is still exactly + one schema for it (Prime Directive #12). +- **`connect()` was optional.** The memory driver needs none; a SQL driver does. + +What deliberately did NOT move: `read-coercion-conformance` keeps its two-driver +matrix (proving a stored value reads back as its declared type on *both* engines +is the entire point of that gate), and the suites whose subject IS the memory +driver or its wiring — `standalone-stack` (`memory://` scheme), +`sqlite-driver-fallback` (the dev step-down), the CLI's driver-label tests, and +driver-memory's own suite. + +`datasource-autoconnect` is in that second group as of #4083, which landed a +regression test there for exactly the memory-pool property this PR originally +proposed to migrate away from. Moving that file to SQLite would have left the +new test passing vacuously — a wasm-SQLite pool never writes `.objectstack/` at +all — so it stays on the memory driver and keeps guarding what it was written +to guard. + +No new coverage is claimed here: each suite asserts exactly what it asserted +before, against a more faithful store. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index ffb5eb50ec..5d8de57740 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -353,34 +353,62 @@ equivalent age-based reap. ## Memory Driver The in-memory driver keeps records in plain in-process objects (queried via -[`mingo`](https://github.com/kofrasa/mingo)). +[`mingo`](https://github.com/kofrasa/mingo)). Data is lost when the process +exits unless persistence is explicitly requested. It is the **last-resort fallback** in dev mode: `objectstack dev` prefers native SQLite (`better-sqlite3`), falls back to the pure-JS WASM SQLite driver if the native binary is unavailable, and only drops to the in-memory driver if WASM also fails to load. Set `OS_DATABASE_DRIVER=memory` to select it explicitly. -**Whether data survives the process depends on how the driver is built** (#4083): +**Both ways of building the driver are ephemeral by default** (#4083, #4065): | How it is built | Persistence | | :--- | :--- | | A **declared datasource** — `{ driver: 'memory' }` in a stack/app config | **Ephemeral.** Nothing is written to disk unless the declaration sets `config.persistence` — and when it does, the destination is scoped per datasource, so two memory datasources never share one file. | -| `new InMemoryDriver()` **constructed directly** | `persistence: 'auto'` — under Node that means a JSON file at `.objectstack/data/memory-driver.json`, relative to the process's working directory, reloaded on the next boot. | - -Pass `persistence: false` for a driver you construct yourself and want purely in -memory — a test, for instance, which should neither depend on nor leave behind -state in the working directory. Two directly-constructed `'auto'` drivers in one -process still share that single default path. +| `new InMemoryDriver()` **constructed directly** | **Ephemeral.** Pass `persistence` to opt in (see below). Note the scoping above is the *factory's* job: two directly-constructed `'auto'` drivers in one process still share the single default path. | ```typescript import { InMemoryDriver } from '@objectstack/driver-memory'; -new InMemoryDriver({ persistence: false }); // pure memory -new InMemoryDriver(); // 'auto' — file-backed under Node +new InMemoryDriver(); // pure memory — the default +new InMemoryDriver({ persistence: 'file' }); // opt in to durability +``` + +### Persistence is opt-in + +A bare `new InMemoryDriver()` persists nothing. Durability is requested +explicitly: + +```typescript +new InMemoryDriver({ persistence: 'file' }) // Node.js — .objectstack/data/memory-driver.json +new InMemoryDriver({ persistence: 'local' }) // browser — localStorage +new InMemoryDriver({ persistence: 'auto' }) // pick per environment (file / localStorage / off) ``` +`'auto'` chooses localStorage in a browser, a file under Node.js, and **disables** +persistence in serverless/edge runtimes (Vercel, Lambda, Netlify, Cloud Run, Deno +Deploy) where a local write would be silently discarded — supply a custom adapter +there instead. + + +Before v17 the default was `'auto'`, which on Node.js meant **file** — so a bare +`new InMemoryDriver()` silently wrote `.objectstack/data/memory-driver.json` into +the working directory and reloaded it on the next boot. If you relied on that, +pass `persistence: 'auto'` (or `'file'`) explicitly. See +[#4065](https://github.com/objectstack-ai/objectstack/issues/4065). + + -Use the memory driver for unit tests. It requires no setup and runs instantly — -with `persistence: false`, so one run cannot see what an earlier run left behind. +For tests, prefer in-memory **SQLite** — `SqlDriver` with +`connection: { filename: ':memory:' }`, or `SqliteWasmDriver({ filename: ':memory:' })` +when you want no native build. Both give the SQL semantics production runs on; +mingo does not enforce primary keys, uniqueness, `NOT NULL` or column types, so a +green run against the memory driver is weaker evidence than it looks. The +framework's own dogfood gate boots on WASM SQLite at `:memory:` for this reason. + +The memory driver remains fine where you want no setup at all and the assertions +do not depend on storage semantics — it is ephemeral by default, so one run +cannot see what an earlier run left behind. ## Local Environment Runtime diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 889b07c592..03593e236e 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -164,12 +164,16 @@ framework surface you install: | Package | What it does | |:---|:---| | `@objectstack/spec` | The protocol: `defineStack`, `ObjectSchema`, `Field.*` builders, all Zod schemas. Your metadata imports only this. | -| `@objectstack/runtime` | The kernel that interprets metadata at runtime. | -| `@objectstack/driver-memory` | In-memory database driver for development. Swap for SQLite / Postgres / MongoDB in production — [no code changes](/docs/data-modeling/drivers). | +| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — you do not install one separately. | | `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. | | `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). | | `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. | +No driver package is listed because none is installed directly: `@objectstack/runtime` +depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries +them too. `objectstack dev` resolves **SQLite** by default; point `OS_DATABASE_URL` at +Postgres / MySQL / MongoDB for production — [no code changes](/docs/data-modeling/drivers). + ## 3. Run it ```bash diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0298fe6352..7c0c8669eb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -103,11 +103,17 @@ export const TEMPLATES: Record { - const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, deferSchemaDdl: true }); + const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, deferSchemaDdl: true, projectRoot: dir }); try { const pending = stack.pendingSchemaWork; const performed = await stack.flushSchemaDdl(); @@ -142,7 +142,7 @@ describe('bootSchemaStack({ deferSchemaDdl }) — the boot writes nothing (#3917 // A second boot finds nothing left to do. await stack.shutdown(); - const again = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, deferSchemaDdl: true }); + const again = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, deferSchemaDdl: true, projectRoot: dir }); try { expect(again.pendingSchemaWork).toEqual([]); } finally { @@ -155,7 +155,7 @@ describe('bootSchemaStack({ deferSchemaDdl }) — the boot writes nothing (#3917 }, 60_000); it('without the flag, the boot syncs as it always did', async () => { - const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}` }); + const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, projectRoot: dir }); try { expect(stack.pendingSchemaWork).toEqual([]); const after = await inspect(); diff --git a/packages/cli/src/utils/schema-migrate.integration.test.ts b/packages/cli/src/utils/schema-migrate.integration.test.ts index 5b28462f0f..8873fa61d9 100644 --- a/packages/cli/src/utils/schema-migrate.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.integration.test.ts @@ -78,7 +78,7 @@ describe('bootSchemaStack + migrate engine (integration)', () => { }); it('detects the NOT NULL + legacy-unique drift, applies both, and self-verifies in-sync', async () => { - const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}` }); + const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, projectRoot: dir }); try { expect(stack.driver).toBeTruthy(); expect(stack.managedTableCount).toBeGreaterThan(0); @@ -198,7 +198,7 @@ describe('bootSchemaStack — dev-provisioned __search companions are not orphan }); it('the migrate boot provisions the companion in metadata, so plan reports no __search drift', async () => { - const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}` }); + const stack = await bootSchemaStack({ databaseUrl: `file:${dbFile}`, projectRoot: dir }); try { expect(stack.driver).toBeTruthy(); diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 4d5ab18487..4c65c8090b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -157,13 +157,25 @@ export async function bootSchemaStack( * tables to exist. */ deferSchemaDdl?: boolean; + /** + * Project root the booted stack scopes its on-disk state to — the default + * sqlite database and the metadata FileSystemRepository + * (`/.objectstack/…`). Defaults to `process.cwd()`, which is + * correct for every real `os migrate` invocation: the CLI runs from the + * project directory. + * + * Tests that assemble a fixture project in a tempdir must pass it, or the + * boot scopes its database to the tempdir while writing metadata into + * whatever directory the test runner happens to be standing in (#4065). + */ + projectRoot?: string; } = {}, ): Promise { const { createStandaloneStack, Runtime } = await import('@objectstack/runtime'); const defer = opts.deferSchemaDdl === true; const stack = await createStandaloneStack({ - projectRoot: process.cwd(), + projectRoot: opts.projectRoot ?? process.cwd(), ...(opts.databaseUrl ? { databaseUrl: opts.databaseUrl } : {}), ...(defer ? { skipSeedData: true } : {}), }); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index f21a6d1a4c..a628977864 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -16,5 +16,14 @@ "sourceMap": true, "types": ["node"] }, - "include": ["src"] + "include": ["src"], + // Tests are not part of the shipped CLI. Compiling them had two costs beyond + // tarball weight: `files: ["dist"]` published them, and — because this package + // has no vitest config — `vitest run` COLLECTED the compiled copies too, so + // every `src/**/*.test.ts` also ran as a stale `dist/**/*.test.js` built from + // whatever the source said at the last build. That is how #4065's + // `packages/cli/.objectstack` residue survived a fix to the source: the run + // was still executing the pre-fix compiled duplicate. It also means a source + // test could be edited to pass while its stale twin asserted the old thing. + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"] } diff --git a/packages/client/package.json b/packages/client/package.json index d1edfa1d91..3593ab06cb 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@hono/node-server": "^2.0.12", - "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/hono": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index 2fec232c9e..00a3ab76f5 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -15,16 +15,21 @@ * 4. `atomic: false` is rejected with 400 BATCH_NOT_ATOMIC — the contract * reason the SDK method exposes no `atomic` flag. * - * NOTE on atomicity: the InMemoryDriver's `transaction()` is a passthrough - * (no rollback), so all-or-nothing semantics are NOT asserted here — they are + * NOTE on atomicity: all-or-nothing semantics are NOT asserted here — they are * covered by objectql/src/engine-ambient-transaction.test.ts and - * rest/src/rest-batch-endpoint.test.ts against transactional drivers. + * rest/src/rest-batch-endpoint.test.ts. This suite's four proofs are about the + * `$ref` wiring, the environment-scoped mirror and the error contract, not + * rollback. (It ran on the mingo driver until #4065, whose `transaction()` is a + * passthrough and could not have asserted rollback even if it wanted to; the + * in-memory SQLite it now runs on does have real transactions, but adding a + * rollback proof here would duplicate the two suites above rather than close a + * gap.) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; @@ -75,7 +80,7 @@ describe('data.batchTransaction (live Hono, #1604)', () => { await kernel.bootstrap(); const ql = kernel.getService('objectql'); - ql.registerDriver(new InMemoryDriver(), true); + ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); ql.registerObject({ name: 'project', @@ -92,6 +97,12 @@ describe('data.batchTransaction (live Hono, #1604)', () => { project: { type: 'lookup', reference_to: 'project', label: 'Project' }, }, }); + // Objects registered AFTER bootstrap miss the boot-time schema sync, so + // nothing has issued their DDL. The mingo driver this suite used before + // #4065 created a table on first touch and hid that; on SQL the first + // write fails with `no such table`. + await ql.syncObjectSchema('project'); + await ql.syncObjectSchema('task'); const httpServer = kernel.getService('http.server'); baseUrl = `http://localhost:${httpServer.getPort()}`; diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index 7fb99a3e56..c44193f500 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; @@ -68,7 +68,7 @@ describe('Project-scoped REST routing (live Hono)', () => { await kernel.bootstrap(); const ql = kernel.getService('objectql'); - ql.registerDriver(new InMemoryDriver(), true); + ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); ql.registerObject({ name: 'task', @@ -77,6 +77,11 @@ describe('Project-scoped REST routing (live Hono)', () => { title: { type: 'text', label: 'Title' }, }, }); + // Objects registered AFTER bootstrap miss the boot-time schema sync, so + // nothing has issued their DDL. The mingo driver this suite used before + // #4065 created a table on first touch and hid that; on SQL the first + // write fails with `no such table`. + await ql.syncObjectSchema('task'); const httpServer = kernel.getService('http.server'); const port = httpServer.getPort(); diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index 185998928d..2a467567d1 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { ObjectStackClient } from './index'; @@ -108,7 +108,7 @@ describe('ObjectStackClient (with Hono Server)', () => { // 3. Setup Driver const ql = kernel.getService('objectql'); - ql.registerDriver(new InMemoryDriver(), true); + ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); // 4. Load Metadata (Schema) ql.registerObject({ @@ -119,6 +119,11 @@ describe('ObjectStackClient (with Hono Server)', () => { email: { type: 'text', label: 'Email' } } }); + // Objects registered AFTER bootstrap miss the boot-time schema sync, so + // nothing has issued their DDL. The mingo driver this suite used before + // #4065 created a table on first touch and hid that; on SQL the first + // write fails with `no such table`. + await ql.syncObjectSchema('customer'); // 5. Get Port from Service const httpServer = kernel.getService('http.server'); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 4cabeb9cf7..06156be9ea 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -13,7 +13,6 @@ "dependencies": { "@objectstack/spec": "^17.0.0", "@objectstack/runtime": "^17.0.0", - "@objectstack/driver-memory": "^17.0.0", "@objectstack/plugin-hono-server": "^17.0.0", "@objectstack/connector-rest": "^17.0.0", "@objectstack/connector-openapi": "^17.0.0", diff --git a/packages/metadata/package.json b/packages/metadata/package.json index c88185368e..882efe6ec5 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -55,6 +55,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@objectstack/driver-sqlite-wasm": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/node": "^26.1.1", "typescript": "^6.0.3", diff --git a/packages/metadata/src/metadata-history.test.ts b/packages/metadata/src/metadata-history.test.ts index 33328f7bd1..193ef648d8 100644 --- a/packages/metadata/src/metadata-history.test.ts +++ b/packages/metadata/src/metadata-history.test.ts @@ -3,15 +3,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { MetadataManager } from './metadata-manager'; import { DatabaseLoader } from './loaders/database-loader'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; describe('Metadata History', () => { let manager: MetadataManager; - let driver: InMemoryDriver; + let driver: SqliteWasmDriver; beforeEach(async () => { - // Create a fresh in-memory driver and database loader - driver = new InMemoryDriver({}); + // A fresh in-memory SQLite database per test + the database loader. + // `DatabaseLoader` provisions its own two tables via `driver.syncSchema`, + // so it needs a CONNECTED driver — the mingo driver this used before #4065 + // needed no connect and created tables on first touch, so neither step was + // visible here. + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); const dbLoader = new DatabaseLoader({ driver, @@ -21,7 +26,7 @@ describe('Metadata History', () => { }); manager = new MetadataManager({ - datasource: 'memory', + datasource: 'sqlite', loaders: [dbLoader], }); }); diff --git a/packages/metadata/vitest.config.ts b/packages/metadata/vitest.config.ts index 10915ef3c0..df7bb9b54a 100644 --- a/packages/metadata/vitest.config.ts +++ b/packages/metadata/vitest.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ resolve: { alias: { '@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'), - '@objectstack/driver-memory': path.resolve(__dirname, '../plugins/driver-memory/src/index.ts'), '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'), '@objectstack/spec/cloud': path.resolve(__dirname, '../spec/src/cloud/index.ts'), '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'), diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index f59f26cf78..cdd52bccb7 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -38,19 +38,28 @@ export interface InMemoryDriverConfig { /** Optional: Logger instance */ logger?: Logger; /** - * Persistence configuration. Defaults to `'auto'`. - * - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled) + * Persistence configuration. **Defaults to `false` — pure in-memory.** + * - `false` (default) — No persistence. `new InMemoryDriver()` keeps nothing + * across process exits, which is what the driver's name promises. + * - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled) * - `'file'` — File-system persistence with defaults (Node.js only) * - `'local'` — localStorage persistence with defaults (Browser only) * - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options * - `{ type: 'local', key?: string }` — localStorage with options * - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options * - `{ adapter: PersistenceAdapterInterface }` — Custom adapter - * - `false` — Disable persistence (pure in-memory) + * + * Durability is **opt-in**, as #815 specified ("默认情况下不启用持久化(纯内存,行为不变)", + * requirement #1, with `new InMemoryDriver()` listed as the pure-memory example). + * The `'auto'` default this driver shipped with was a drift from that accepted + * design, and it silently wrote `.objectstack/data/memory-driver.json` into the + * process CWD on every Node.js run — which made any suite seeded with fixed ids + * pass once and fail on every rerun (#4065). A host that wants durability now + * says so: `persistence: 'file'` / `'local'` / `'auto'`. * * ⚠️ In serverless environments (Vercel, AWS Lambda, Netlify, etc.), * auto mode disables file persistence to prevent silent data loss. - * Use `persistence: false` or supply a custom adapter for serverless deployments. + * Supply a custom adapter for serverless deployments. */ persistence?: string | false | { type?: 'file' | 'local' | 'auto'; @@ -71,10 +80,10 @@ interface MemoryTransaction { /** * In-Memory Driver for ObjectStack - * - * A production-ready implementation of the ObjectStack Driver Protocol - * powered by Mingo — a MongoDB-compatible query and aggregation engine. - * + * + * An implementation of the ObjectStack Driver Protocol powered by Mingo — a + * MongoDB-compatible query and aggregation engine. + * * Features: * - MongoDB-compatible query engine (Mingo) for filtering, projection, aggregation * - Full CRUD and bulk operations @@ -82,7 +91,27 @@ interface MemoryTransaction { * - Snapshot-based transactions (begin/commit/rollback) * - Field projection and distinct values * - Strict mode and initial data loading - * + * + * ## What this driver does NOT enforce + * + * It stores no constraints of any kind. {@link create} is a `table.push()` and + * {@link syncSchema} only allocates an array and indexes temporal fields, so + * there is no primary key, no uniqueness, no `NOT NULL`, no foreign key and no + * column typing. `bulkCreate` will happily land two rows with the same `id` + * where a SQL driver raises a constraint violation, and a read returns both. + * + * That makes it a WEAK oracle: code green against this driver can still be + * broken against the SQL engines production runs on. Prefer in-memory SQLite + * for tests — `SqlDriver` with `connection: { filename: ':memory:' }`, or + * `SqliteWasmDriver({ filename: ':memory:' })` where no native build is wanted. + * This driver's remaining roles are the last-resort rung of the dev step-down + * (native better-sqlite3 → WASM SQLite → here), browser/edge runtimes where no + * SQLite build is available, and the cross-driver read-coercion parity gate. + * + * The docstring said "production-ready" until #4065; the constraints above were + * true then too, and saying so is the fix (Prime Directive #10 — never advertise + * a capability the runtime does not deliver). + * * Reference: objectql/packages/drivers/memory */ export class InMemoryDriver implements IDataDriver { @@ -1323,15 +1352,17 @@ export class InMemoryDriver implements IDataDriver { /** * Initialize the persistence adapter based on configuration. - * Defaults to 'auto' when persistence is not specified. - * Use `persistence: false` to explicitly disable persistence. * - * In serverless environments (Vercel, AWS Lambda, etc.), auto mode disables - * file-system persistence and emits a warning. Use `persistence: false` or - * supply a custom adapter for serverless-safe operation. + * **Persistence is opt-in.** An omitted `persistence` means none — the driver + * is pure in-memory, matching its name and #815's requirement #1. Ask for + * durability explicitly with `'auto'` / `'file'` / `'local'` / a custom adapter. + * + * In serverless environments (Vercel, AWS Lambda, etc.), `'auto'` disables + * file-system persistence and emits a warning; supply a custom adapter for + * serverless-safe durability. */ private async initPersistence(): Promise { - const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence; + const persistence = this.config.persistence ?? false; if (persistence === false) return; if (typeof persistence === 'string') { diff --git a/packages/plugins/driver-memory/src/persistence/persistence.test.ts b/packages/plugins/driver-memory/src/persistence/persistence.test.ts index 073ebd3775..280b22bc52 100644 --- a/packages/plugins/driver-memory/src/persistence/persistence.test.ts +++ b/packages/plugins/driver-memory/src/persistence/persistence.test.ts @@ -6,6 +6,26 @@ import * as path from 'node:path'; const TEST_DATA_DIR = path.join('/tmp', 'objectstack-test-persistence'); const TEST_FILE_PATH = path.join(TEST_DATA_DIR, 'test-db.json'); +/** + * Run `fn` with the process CWD moved to a scratch dir, then restore it. + * + * The shorthand forms (`persistence: 'file'` / `'auto'`) deliberately take the + * adapter's DEFAULT path, which is `.objectstack/data/memory-driver.json` + * *relative to the CWD* — so without this the suite wrote that file into + * `packages/plugins/driver-memory/` and the next run loaded it back. A unit + * test must not have write side effects on the package directory (#4065). + */ +async function inScratchCwd(fn: () => Promise): Promise { + const prev = process.cwd(); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.chdir(TEST_DATA_DIR); + try { + await fn(); + } finally { + process.chdir(prev); + } +} + describe('InMemoryDriver Persistence', () => { beforeEach(() => { // Clean up test directory @@ -52,11 +72,14 @@ describe('InMemoryDriver Persistence', () => { }); it('should support shorthand "file" persistence string', async () => { - // Use shorthand — just verifies no error is thrown with 'file' - const driver = new InMemoryDriver({ persistence: 'file' }); - await driver.connect(); - await driver.create('items', { id: '1', name: 'Widget' }); - await driver.disconnect(); + // Use shorthand — just verifies no error is thrown with 'file'. + // Scratch CWD: the shorthand takes the adapter's default relative path. + await inScratchCwd(async () => { + const driver = new InMemoryDriver({ persistence: 'file' }); + await driver.connect(); + await driver.create('items', { id: '1', name: 'Widget' }); + await driver.disconnect(); + }); }); it('should persist updates and deletes', async () => { @@ -157,11 +180,15 @@ describe('InMemoryDriver Persistence', () => { describe('Auto Persistence', () => { it('should auto-detect Node.js environment and use file persistence with shorthand', async () => { - // In Node.js, 'auto' should select file persistence - const driver = new InMemoryDriver({ persistence: 'auto' }); - await driver.connect(); - await driver.create('items', { id: '1', name: 'Widget' }); - await driver.disconnect(); + // In Node.js, 'auto' should select file persistence. + // Scratch CWD: the shorthand takes the adapter's default relative path. + await inScratchCwd(async () => { + const driver = new InMemoryDriver({ persistence: 'auto' }); + await driver.connect(); + await driver.create('items', { id: '1', name: 'Widget' }); + await driver.disconnect(); + expect(fs.existsSync(path.join('.objectstack', 'data', 'memory-driver.json'))).toBe(true); + }); }); it('should auto-detect Node.js environment and use file persistence with object config', async () => { @@ -213,6 +240,53 @@ describe('InMemoryDriver Persistence', () => { }); }); + // The default is the whole bug in #4065, and nothing pinned it — every test + // in this file passes `persistence` explicitly, so the driver was free to + // drift from #815's requirement #1 ("默认情况下不启用持久化(纯内存,行为不变)") + // to a silent `'auto'` → file write without a single assertion turning red. + describe('Default persistence (opt-in)', () => { + it('writes nothing to the CWD when persistence is not configured', async () => { + await inScratchCwd(async () => { + const driver = new InMemoryDriver(); + await driver.connect(); + await driver.create('items', { id: 'a', name: 'Widget' }); + await driver.disconnect(); + // The path the file adapter defaults to, relative to the CWD. + expect(fs.existsSync(path.join('.objectstack', 'data', 'memory-driver.json'))).toBe(false); + }); + }); + + it('does not carry rows across driver instances (the #4065 rerun shape)', async () => { + await inScratchCwd(async () => { + // Two instances seeded with the SAME fixed ids, exactly as a suite that + // runs twice in one working tree does. Under the old `'auto'` default + // the second instance loaded the first's rows and `find` returned four. + for (const _run of [1, 2]) { + const driver = new InMemoryDriver(); + await driver.connect(); + await driver.bulkCreate('ext_note', [ + { id: 'n1', title: 'first' }, + { id: 'n2', title: 'second' }, + ]); + const rows = await driver.find('ext_note', { object: 'ext_note' }); + expect(rows.map((r: any) => r.title).sort()).toEqual(['first', 'second']); + await driver.disconnect(); + } + }); + }); + + it('still persists when a host opts in explicitly', async () => { + const filePath = path.join(TEST_DATA_DIR, 'opt-in.json'); + const driver = new InMemoryDriver({ + persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + }); + await driver.connect(); + await driver.create('items', { id: 'a', name: 'Widget' }); + await driver.disconnect(); + expect(fs.existsSync(filePath)).toBe(true); + }); + }); + describe('Serverless Environment Detection', () => { const serverlessEnvVars = [ 'VERCEL', diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 929f815d6d..adfd0c6402 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -538,7 +538,12 @@ export class DevPlugin implements Plugin { try { const { DriverPlugin } = await import('@objectstack/runtime') as any; const { InMemoryDriver } = await import('@objectstack/driver-memory') as any; - const driver = new InMemoryDriver(); + // Ephemeral, like every other service this plugin stubs (cache, queue, + // job, i18n, storage, search are all in-memory). Stated explicitly + // rather than inherited: the driver used to default to writing + // `.objectstack/data/memory-driver.json` into the CWD, which made this + // stack the one piece of DevPlugin that quietly outlived the process. + const driver = new InMemoryDriver({ persistence: false }); const driverPlugin = new DriverPlugin(driver, 'memory'); this.childPlugins.push(driverPlugin); ctx.logger.info(' ✔ InMemoryDriver enabled'); diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 5898f53ffb..714a2562a2 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -3,7 +3,7 @@ "version": "0.0.6-rc.0", "private": true, "license": "Apache-2.0", - "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", + "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) \u2014 a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", "type": "module", "scripts": { "test": "vitest run" @@ -12,7 +12,7 @@ "@objectstack/core": "workspace:*" }, "devDependencies": { - "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/runtime": "workspace:*", diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index 876c28eba6..614227b935 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createDispatcherPlugin, createRestApiPlugin } from '@objectstack/runtime'; import { NodeServerPlugin } from './node-plugin.js'; @@ -74,7 +74,15 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean await kernel.bootstrap(); const ql = kernel.getService('objectql'); - ql.registerDriver(new InMemoryDriver(), true); + // In-memory SQLite (pure-JS WASM, no native build) — the same engine + // `@objectstack/verify`'s bootStack runs the dogfood gate on. `connect()` + // is NOT optional the way it was for the mingo driver this used before + // #4065: an unconnected datasource is an UNAVAILABLE one, and every data + // entry point then reports the object as unregistered (a 404 that looks + // like a routing bug and is really a boot-order one). + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + ql.registerDriver(driver as never, true); ql.registerObject({ name: 'task', label: 'Task', @@ -82,6 +90,12 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean title: { type: 'text', label: 'Title' }, }, }); + // Registering an object after bootstrap misses the boot-time schema sync, + // so nothing has issued this table's DDL. The mingo driver this fixture used + // before #4065 created a table on first touch and hid that; on SQL the + // insert fails with `no such table`, which the REST error mapper turns into + // a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a DDL-shaped cause. + await ql.syncObjectSchema('task'); const httpServer = kernel.getService('http.server'); return { kernel, base: `http://127.0.0.1:${httpServer.getPort()}` }; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 743d04332e..cc2e39d3ae 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -44,6 +44,7 @@ "@objectstack/driver-mongodb": "workspace:*" }, "devDependencies": { + "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-messaging": "workspace:*", diff --git a/packages/runtime/src/datasource-autoconnect.test.ts b/packages/runtime/src/datasource-autoconnect.test.ts index a7d6e87cd0..dbd413d0c4 100644 --- a/packages/runtime/src/datasource-autoconnect.test.ts +++ b/packages/runtime/src/datasource-autoconnect.test.ts @@ -138,7 +138,8 @@ describe('ADR-0062 declared-datasource auto-connect', () => { // #4083 — the acceptance above passed on a clean checkout and failed on every // subsequent run, reading 2×N rows on the Nth: the auto-connected `memory` -// datasource inherited `InMemoryDriver`'s `persistence: 'auto'` default, so it +// datasource inherited `InMemoryDriver`'s then-default `persistence: 'auto'` +// (#4065 has since made that default `false`), so it // flushed `ext_note` into `.objectstack/data/memory-driver.json` under the CWD // and the next boot's connect() loaded those rows back before this file seeded // its own. CI never caught it because CI always runs #1 on a fresh checkout. diff --git a/packages/runtime/src/datasource-visibility.test.ts b/packages/runtime/src/datasource-visibility.test.ts index 7e2520a83d..24db33a550 100644 --- a/packages/runtime/src/datasource-visibility.test.ts +++ b/packages/runtime/src/datasource-visibility.test.ts @@ -52,14 +52,14 @@ describe('code-defined datasource visibility (ADR-0015 §18)', () => { beforeAll(async () => { const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { InMemoryDriver } = await import('@objectstack/driver-memory'); + const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); const { DatasourceAdminServicePlugin } = await import('@objectstack/service-datasource'); // Host-config shape: NO MetadataPlugin — the kernel auto-injects its // in-memory `metadata` fallback (CORE_FALLBACK_FACTORIES.metadata). const runtime = new Runtime({ cluster: false }); kernel = runtime.getKernel(); - await kernel.use(new DriverPlugin(new InMemoryDriver())); + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); await kernel.use(new ObjectQLPlugin()); await kernel.use(new AppPlugin(ARTIFACT)); await kernel.use(new DatasourceAdminServicePlugin({})); diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index 042bd42a52..f4ecfd03ae 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -146,8 +146,8 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# // rebuild. createPrebuiltDriverFactory wraps it; the plugin's connect // orchestration must register THAT instance — never a rebuilt copy. const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource'); - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const hostBuilt = new InMemoryDriver(); + const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); + const hostBuilt = new SqliteWasmDriver({ filename: ':memory:' }); const kernel = await assemble({ driver: 'turso', // a kind the SHARED factory does not support — proves dispatch factory: createPrebuiltDriverFactory(hostBuilt, { driverId: 'turso' }), @@ -211,8 +211,8 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# // registry cache). An LRU eviction shutting the kernel down must not pull // the pool from under every other consumer. const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource'); - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const hostBuilt = new InMemoryDriver() as any; + const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); + const hostBuilt = new SqliteWasmDriver({ filename: ':memory:' }) as any; let disconnects = 0; const orig = hostBuilt.disconnect?.bind(hostBuilt); hostBuilt.disconnect = async () => { disconnects += 1; return orig?.(); }; diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index d19fb4a782..30acccaa6d 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -4,11 +4,13 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging'; +import { SysNotification } from '@objectstack/platform-objects'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; +import { AppPlugin } from './app-plugin.js'; /** * End-to-end regression for framework #3362 (`#3354 not effective on hono`). @@ -67,13 +69,27 @@ describe('in-app notifications over a real hono server (integration, #3362)', () beforeAll(async () => { kernel = new ObjectKernel({ logLevel: 'silent' }); - // An in-memory driver backs persistence; ObjectQL (registered after the + // In-memory SQLite backs persistence; ObjectQL (registered after the // driver so it discovers it) provides `objectql` + `data` + `manifest`; // MessagingServicePlugin registers the `notification` service the dispatcher // resolves and owns the inbox tables. Inline delivery (reliableDelivery:false) // writes the inbox row synchronously so `emit()` is observable immediately. - await kernel.use(new DriverPlugin(new InMemoryDriver())); + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); await kernel.use(new ObjectQLPlugin()); + // The L2 event object `sys_notification` is a PLATFORM object, declared in + // `@objectstack/platform-objects` — MessagingServicePlugin writes it but + // does not declare it, so this lean kernel (no platform-objects boot) has to + // register it or the engine has no schema to issue DDL from. Under the + // mingo driver this suite used before #4065 the omission was invisible: it + // auto-creates a table on first touch, so a missing declaration read as + // working. Registering the REAL object rather than a hand-copied stand-in + // keeps one schema (Prime Directive #12). + await kernel.use( + new AppPlugin({ + manifest: { id: 'com.test.notifications-e2e', name: 'Notifications E2E', version: '1.0.0' }, + objects: [SysNotification], + } as never), + ); await kernel.use(new MessagingServicePlugin({ reliableDelivery: false })); await kernel.use(fakeAuthPlugin()); // port 0 → OS-assigned free port; resolved via getPort() after listening. diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ee2ac06d27..84529b980e 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -68,10 +68,16 @@ export const StandaloneStackConfigSchema = z.object({ * Project root directory. When set (typically by the CLI after locating * `objectstack.config.ts`), the default sqlite database is placed under * `/.objectstack/data/standalone.db` instead of the global - * `~/.objectstack/data/standalone.db`. This keeps per-project data - * scoped to the project folder so different examples / apps don't + * `~/.objectstack/data/standalone.db`, and the metadata FileSystemRepository + * roots at `/.objectstack/metadata`. This keeps per-project + * data scoped to the project folder so different examples / apps don't * share a single database by accident. * + * Both halves matter: until #4065 only the database honoured it while the + * metadata repository still used `process.cwd()`, so a boot whose + * projectRoot pointed elsewhere silently wrote `.objectstack/metadata/` + * into the current directory. + * * Explicit `databaseUrl` / `OS_DATABASE_URL` / `OS_HOME` still take * precedence over this default. */ @@ -326,6 +332,14 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro artifactWatch: process.env.NODE_ENV !== 'production', environmentId, artifactSource: { mode: 'local-file', path: artifactPath }, + // `projectRoot` has to reach the metadata repository too. It already + // redirects the default sqlite database; without this line the + // FileSystemRepository still rooted at `process.cwd()`, so one + // "project root" meant two different directories and a boot pointed + // at some other project wrote `.objectstack/metadata/` into whatever + // directory the process happened to be standing in (#4065). Omitted + // when unset so the plugin keeps its own cwd default. + ...(cfg.projectRoot ? { rootDir: cfg.projectRoot } : {}), }), new ObjectQLPlugin({ environmentId }), ]; diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 8bd526c2d9..5eb4520ac4 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -17,6 +17,10 @@ export default defineConfig({ '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'), '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'), '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'), + // Reached via `@objectstack/platform-objects` (sys-user.object.ts), which + // notifications.hono.integration.test.ts pulls in for the real + // `sys_notification` declaration. + '@objectstack/spec/identity': path.resolve(__dirname, '../spec/src/identity/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'), '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'), diff --git a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts index 6020a0d547..bb310013f9 100644 --- a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts +++ b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts @@ -77,8 +77,10 @@ describe('createDefaultDatasourceDriverFactory — mysql construction', () => { }); }); -// #4083 — the `memory` id built a bare `new InMemoryDriver()`, inheriting that -// driver's own `persistence: 'auto'` default: in Node a file adapter at the +// #4083 — the `memory` id built a bare `new InMemoryDriver()`, inheriting the +// driver's THEN-default `persistence: 'auto'` (#4065 has since made that default +// `false`, so this suite now pins the factory's own guarantee rather than a +// correction to the driver's): in Node a file adapter at the // RELATIVE, process-global path `.objectstack/data/memory-driver.json`. So a // "memory" datasource flushed its store into the server's CWD at teardown and // reloaded it on the next boot, and every memory pool in the process aliased the diff --git a/packages/services/service-datasource/src/sqlite-driver-fallback.ts b/packages/services/service-datasource/src/sqlite-driver-fallback.ts index f84e337a5b..2561a4a710 100644 --- a/packages/services/service-datasource/src/sqlite-driver-fallback.ts +++ b/packages/services/service-datasource/src/sqlite-driver-fallback.ts @@ -189,10 +189,13 @@ export async function resolveSqliteDriver( } // 3. In-memory (mingo) — dev-only last resort. Not real SQL, not persistent. - // `persistence: false` is what makes that second half true: the driver's own - // default is `'auto'`, which in Node flushes the whole store to - // `.objectstack/data/memory-driver.json` in the CWD and reloads it next boot — - // a shared file that every memory pool in the process would alias (#4083). + // `persistence: false` says the second half out loud. It used to be the only + // thing making it true: the driver defaulted to `'auto'`, which in Node + // flushed the whole store to `.objectstack/data/memory-driver.json` in the CWD + // and reloaded it next boot — a shared file every memory pool in the process + // aliased (#4083). #4065 made `false` the driver's own default, so this is now + // a restatement rather than a correction; it stays because a rung that MUST + // NOT persist should not be one edit away from persisting again. const { InMemoryDriver } = await import('@objectstack/driver-memory'); warn(NATIVE_SQLITE_MEMORY_FALLBACK_WARNING); return { diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index a85320a6c3..b5098ea798 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -12,16 +12,29 @@ import { } from './memory.zod'; describe('MemoryConfigSchema', () => { - it('should default persistence to "auto" when empty config', () => { + // Persistence is OPT-IN (#815 requirement #1, restored in #4065). This + // schema previously defaulted to `'auto'`, which on Node.js resolves to file + // persistence — so an unconfigured memory driver wrote + // `.objectstack/data/memory-driver.json` into the process CWD and reloaded it + // on the next boot. The spec and the driver agreed on `'auto'`, which is why + // the drift survived; what neither checked was whether `'auto'` was the + // default #815 accepted. It was not. + it('should default persistence to false (pure in-memory) when empty config', () => { const config = MemoryConfigSchema.parse({}); expect(config.strictMode).toBe(false); expect(config.initialData).toBeUndefined(); - expect(config.persistence).toBe('auto'); + expect(config.persistence).toBe(false); expect(config.indexes).toBeUndefined(); expect(config.maxRecordsPerObject).toBeUndefined(); }); + it('still accepts "auto" as an explicit opt-in to the previous behaviour', () => { + const config = MemoryConfigSchema.parse({ persistence: 'auto' }); + + expect(config.persistence).toBe('auto'); + }); + it('should accept persistence: false to disable persistence', () => { const config = MemoryConfigSchema.parse({ persistence: false, diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 232c486b5a..9a732840f9 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -187,39 +187,41 @@ export const MemoryConfigSchema = lazySchema(() => z.object({ strictMode: z.boolean().default(false).describe('Throw on missing records instead of returning null'), /** - * Persistence configuration. - * Defaults to `'auto'` — the memory store automatically detects the environment - * and saves/restores data using the best available strategy. + * Persistence configuration. **Defaults to `false` — pure in-memory.** * - * - `'auto'` (default): Auto-detect environment (browser → localStorage, Node.js → file) + * Durability is opt-in, per #815's requirement #1 ("默认情况下不启用持久化(纯内存, + * 行为不变)"). A driver named "in-memory" that silently wrote to the process CWD + * surprised every caller that never asked for it, and made fixed-id fixtures pass + * on the first run and fail on every rerun (#4065). + * + * - `false` (default): No persistence — pure in-memory, data lost on disconnect + * - `'auto'`: Auto-detect environment (browser → localStorage, Node.js → file) * - `'file'`: Persist to disk file (Node.js only, default path: `.objectstack/data/memory-driver.json`) * - `'local'`: Persist to localStorage (Browser only, default key: `objectstack:memory-db`) * - `{ type: 'auto', path?: string, key?: string }`: Auto-detect with overrides * - `{ type: 'file', path?: string }`: File-system with custom path * - `{ type: 'local', key?: string }`: localStorage with custom key * - `{ adapter: PersistenceAdapter }`: Custom persistence adapter - * - `false`: Disable persistence (pure in-memory, data lost on disconnect) * * **⚠️ Serverless / Edge environments (Vercel, AWS Lambda, Netlify, etc.):** - * Auto mode detects serverless runtimes and disables file persistence to prevent - * silent data loss. Set `persistence: false` to opt-in to pure in-memory mode, - * or supply a custom adapter (e.g. Upstash Redis, Vercel KV) for durable storage. + * `'auto'` detects serverless runtimes and disables file persistence to prevent + * silent data loss. Supply a custom adapter (e.g. Upstash Redis, Vercel KV) for + * durable storage there. * * @example - * // Auto-detect environment (default) + * // Pure memory — the default * new InMemoryDriver() - * // Node.js + * // Node.js, durable across restarts * new InMemoryDriver({ persistence: 'file' }) - * // Browser + * // Browser, durable across reloads * new InMemoryDriver({ persistence: 'local' }) - * // Pure memory (no persistence) - * new InMemoryDriver({ persistence: false }) + * // Auto-detect the environment's best strategy + * new InMemoryDriver({ persistence: 'auto' }) * // Custom adapter for serverless * new InMemoryDriver({ persistence: { adapter: upstashAdapter } }) * * **A datasource declared with `driver: 'memory'` is ephemeral unless this - * key is set (#4083).** `'auto'` is the default only for a driver you - * construct yourself. The shared datasource factory + * key is set (#4083).** The shared datasource factory * (`createDefaultDatasourceDriverFactory`) passes `false` when the * declaration says nothing, so a declared memory datasource cannot silently * become a file-backed store in the process's working directory — matching @@ -227,8 +229,15 @@ export const MemoryConfigSchema = lazySchema(() => z.object({ * user opting out of persistence. Set this key to opt back in; when you do * without naming a `path`/`key`, the factory scopes the destination per * datasource, so two memory datasources never share one file. + * + * That per-datasource scoping is still the reason to route a memory pool + * through the factory: #4065 made the DRIVER's own default `false` too, so + * the factory's explicit `false` is now belt-and-braces rather than the only + * thing standing between a declared datasource and a CWD write — but nothing + * else expands `'auto'`/`'file'`/`'local'` into a per-datasource destination, + * so two pools that DO opt in still need it to avoid aliasing one file. */ - persistence: MemoryPersistenceConfigSchema.or(z.literal(false)).default('auto').describe('Persistence configuration (auto-detect by default; a declared memory datasource is ephemeral unless set)'), + persistence: MemoryPersistenceConfigSchema.or(z.literal(false)).default(false).describe('Persistence configuration (opt-in; defaults to pure in-memory)'), /** * Fields to index for faster lookups. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fcd35d620..7192ecd25d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,9 +573,9 @@ importers: '@hono/node-server': specifier: ^2.0.12 version: 2.0.12(hono@4.12.32) - '@objectstack/driver-memory': + '@objectstack/driver-sqlite-wasm': specifier: workspace:* - version: link:../plugins/driver-memory + version: link:../plugins/driver-sqlite-wasm '@objectstack/hono': specifier: workspace:* version: link:../adapters/hono @@ -887,6 +887,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/driver-sqlite-wasm': + specifier: workspace:* + version: link:../plugins/driver-sqlite-wasm '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -1683,9 +1686,9 @@ importers: specifier: workspace:* version: link:../../core devDependencies: - '@objectstack/driver-memory': + '@objectstack/driver-sqlite-wasm': specifier: workspace:* - version: link:../../plugins/driver-memory + version: link:../../plugins/driver-sqlite-wasm '@objectstack/objectql': specifier: workspace:* version: link:../../objectql @@ -1811,6 +1814,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../platform-objects '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugins/plugin-hono-server