From 21d3a41012cc4c5b666238ee6d19cc45e03e76db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:10:41 +0400 Subject: [PATCH] feat(mockapi): generate shared state store for mock server Replace the generated repository-style state access with a shared state store that works across browser and Node runtimes. This adds the new state-store template, updates the generator and runtime wiring, and refreshes the supporting docs and tests to use the new store API. --- .../assets/templates/mock-server/package.json | 5 + .../templates/mock-server/src/browser.ts | 13 + .../templates/mock-server/src/dependencies.ts | 12 + .../generated/mock-admin/state/repository.ts | 108 ------- .../src/generated/mock-admin/state/service.ts | 16 +- .../mock-server/src/lib/browserStateStore.ts | 55 ++++ .../mock-server/src/lib/nodeStateStore.ts | 66 ++++ .../templates/mock-server/src/server.ts | 4 +- skills/mockapi/reference/generate.md | 24 +- skills/mockapi/reference/generated-server.md | 57 ++-- .../mockapi/reference/mock-server-examples.md | 144 ++++----- .../mockapi/reference/mock-server-quality.md | 5 +- .../reference/mock-server-structure.md | 33 +- skills/mockapi/reference/testing.md | 2 + .../mockapi/scripts/mockapi_runtime/files.py | 1 - .../scripts/mockapi_runtime/quality.py | 8 + .../scripts/mockapi_runtime/render_project.py | 98 +++++- .../mockapi_runtime/templates/app.ts.tpl | 5 +- .../templates/controllers.ts.tpl | 29 +- .../templates/state-store.ts.tpl | 303 ++++++++++++++++++ tests/support.py | 42 ++- tests/test_files.py | 4 +- tests/test_generate.py | 12 +- tests/test_quality.py | 23 +- tests/test_render_services.py | 18 ++ tests/test_template_contents.py | 16 + 26 files changed, 831 insertions(+), 272 deletions(-) create mode 100644 skills/mockapi/assets/templates/mock-server/src/browser.ts create mode 100644 skills/mockapi/assets/templates/mock-server/src/dependencies.ts delete mode 100644 skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/repository.ts create mode 100644 skills/mockapi/assets/templates/mock-server/src/lib/browserStateStore.ts create mode 100644 skills/mockapi/assets/templates/mock-server/src/lib/nodeStateStore.ts create mode 100644 skills/mockapi/scripts/mockapi_runtime/templates/state-store.ts.tpl diff --git a/skills/mockapi/assets/templates/mock-server/package.json b/skills/mockapi/assets/templates/mock-server/package.json index 8aecd28..06eaa76 100644 --- a/skills/mockapi/assets/templates/mock-server/package.json +++ b/skills/mockapi/assets/templates/mock-server/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.1.0", "type": "module", + "exports": { + "./browser": "./src/browser.ts", + "./package.json": "./package.json" + }, "scripts": { "build": "node scripts/build.mjs", "check": "tsc -p tsconfig.json --noEmit", @@ -14,6 +18,7 @@ }, "dependencies": { "@hono/node-server": "^2.0.4", + "@msw/data": "1.1.6", "hono": "^4.10.7", "zod": "^4.3.6" }, diff --git a/skills/mockapi/assets/templates/mock-server/src/browser.ts b/skills/mockapi/assets/templates/mock-server/src/browser.ts new file mode 100644 index 0000000..d156d17 --- /dev/null +++ b/skills/mockapi/assets/templates/mock-server/src/browser.ts @@ -0,0 +1,13 @@ +// @ts-nocheck -- mockapi template source; stripped during generation +export { + newMockApiDependencies, + type MockApiDependencies, +} from './dependencies.ts' +export { + newBrowserMockStateStore, + type BrowserMockStateStoreOptions, +} from './lib/browserStateStore.ts' +export { seedState } from './generated/mock-admin/state/seed.ts' +export { zMockState } from './generated/mock-admin/contract/zod.gen.ts' +export type { MockState } from './generated/mock-admin/contract/index.ts' +export type { MockStateStore } from './lib/stateStore.ts' diff --git a/skills/mockapi/assets/templates/mock-server/src/dependencies.ts b/skills/mockapi/assets/templates/mock-server/src/dependencies.ts new file mode 100644 index 0000000..aba755e --- /dev/null +++ b/skills/mockapi/assets/templates/mock-server/src/dependencies.ts @@ -0,0 +1,12 @@ +// @ts-nocheck -- mockapi template source; stripped during generation +import type { MockStateStore } from './lib/stateStore.ts' + +export type MockApiDependencies = { + stateStore: MockStateStore +} + +export const newMockApiDependencies = ( + stateStore: MockStateStore, +): MockApiDependencies => ({ + stateStore, +}) diff --git a/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/repository.ts b/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/repository.ts deleted file mode 100644 index bf917f8..0000000 --- a/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/repository.ts +++ /dev/null @@ -1,108 +0,0 @@ -// @ts-nocheck -- mockapi template source; stripped during generation -// This file is generated by mockapi. -// Do not edit by hand. - -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'node:fs' -import path from 'node:path' - -import { clone } from '../../../lib/clone.ts' -import type { MockState } from '../contract/index.ts' -import { seedState } from './seed.ts' - -export type MockStateOptions = { - initialState?: MockState - stateFile?: string -} - -const dayMs = 24 * 60 * 60 * 1000 - -export class MockStateRepository { - private state: MockState - private readonly stateFile: string | undefined - - constructor(options: MockStateOptions = {}) { - this.stateFile = options.stateFile - this.state = options.initialState - ? clone(options.initialState) - : this.readPersistedState() ?? seedState() - this.persist() - } - - reset() { - this.state = seedState() - this.persist() - - return this.snapshot() - } - - replace(state: MockState) { - this.state = clone(state) - this.persist() - - return this.snapshot() - } - - setClock(now: string) { - const clock = this.getSlice('clock') - - clock.now = now - this.persist() - - return clone(clock) - } - - getSlice(key: TKey) { - return this.state[key] - } - - setSlice(key: TKey, value: MockState[TKey]) { - this.state[key] = value - } - - snapshot() { - return clone(this.state) - } - - now() { - return this.getSlice('clock').now - } - - daysFromNow(days: number) { - return new Date(Date.parse(this.now()) + days * dayMs).toISOString() - } - - transaction(callback: () => T): T { - const result = callback() - this.persist() - - return result - } - - private persist() { - if (!this.stateFile) { - return - } - - mkdirSync(path.dirname(this.stateFile), { recursive: true }) - writeFileSync(this.stateFile, `${JSON.stringify(this.state, null, 2)}\n`, 'utf8') - } - - private readPersistedState() { - if (!this.stateFile || !existsSync(this.stateFile)) { - return null - } - - try { - return JSON.parse(readFileSync(this.stateFile, 'utf8')) as MockState - } catch { - console.warn(`Ignoring invalid mock state at ${this.stateFile}. Resetting to seed state.`) - - return null - } - } -} diff --git a/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/service.ts b/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/service.ts index a15e824..23c5ef6 100644 --- a/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/service.ts +++ b/skills/mockapi/assets/templates/mock-server/src/generated/mock-admin/state/service.ts @@ -3,11 +3,11 @@ // Do not edit by hand. import type { MockClock, MockState } from '../contract/index.ts' -import type { MockStateRepository } from './repository.ts' +import type { MockStateStore } from '../../../lib/stateStore.ts' export class AdminStateService { constructor( - private readonly stateRepository: MockStateRepository, + private readonly stateStore: MockStateStore, private readonly operationCount: number, ) {} @@ -19,26 +19,26 @@ export class AdminStateService { } reset() { - return this.stateRepository.reset() + return this.stateStore.reset() } getState() { - return this.stateRepository.snapshot() + return this.stateStore.snapshot() } replaceState(state: MockState) { - return this.stateRepository.replace(state) + return this.stateStore.replace(state) } snapshot() { - return this.stateRepository.snapshot() + return this.stateStore.snapshot() } putSnapshot(state: MockState) { - return this.stateRepository.replace(state) + return this.stateStore.replace(state) } setClock(clock: MockClock) { - return this.stateRepository.setClock(clock.now) + return this.stateStore.setClock(clock.now) } } diff --git a/skills/mockapi/assets/templates/mock-server/src/lib/browserStateStore.ts b/skills/mockapi/assets/templates/mock-server/src/lib/browserStateStore.ts new file mode 100644 index 0000000..0845431 --- /dev/null +++ b/skills/mockapi/assets/templates/mock-server/src/lib/browserStateStore.ts @@ -0,0 +1,55 @@ +// @ts-nocheck -- mockapi template source; stripped during generation +import { clone } from './clone.ts' +import { + newMswDataStateStore, + type MswDataStateStore, +} from './stateStore.ts' +import type { MockState } from '../generated/mock-admin/contract/index.ts' +import { zMockState } from '../generated/mock-admin/contract/zod.gen.ts' +import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type BrowserMockStateStoreOptions = { + initialState?: MockState + storageKey: string +} + +export const newBrowserMockStateStore = async ( + options: BrowserMockStateStoreOptions, +): Promise => { + const initialState = options.initialState + ? clone(options.initialState) + : readPersistedState(options.storageKey) ?? seedState() + + return newMswDataStateStore({ + initialState, + persist: (state) => persistState(options.storageKey, state), + }) +} + +const persistState = (storageKey: string, state: MockState) => { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(storageKey, JSON.stringify(state)) +} + +const readPersistedState = (storageKey: string) => { + if (typeof window === 'undefined') { + return null + } + + const raw = window.localStorage.getItem(storageKey) + + if (!raw) { + return null + } + + try { + const result = zMockState.safeParse(JSON.parse(raw)) + + return result.success ? result.data : null + } catch { + return null + } +} diff --git a/skills/mockapi/assets/templates/mock-server/src/lib/nodeStateStore.ts b/skills/mockapi/assets/templates/mock-server/src/lib/nodeStateStore.ts new file mode 100644 index 0000000..4ad1a63 --- /dev/null +++ b/skills/mockapi/assets/templates/mock-server/src/lib/nodeStateStore.ts @@ -0,0 +1,66 @@ +// @ts-nocheck -- mockapi template source; stripped during generation +import { + mkdir, + readFile, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' + +import { clone } from './clone.ts' +import { + newMswDataStateStore, + type MswDataStateStore, +} from './stateStore.ts' +import type { MockState } from '../generated/mock-admin/contract/index.ts' +import { zMockState } from '../generated/mock-admin/contract/zod.gen.ts' +import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type MockStateOptions = { + initialState?: MockState + stateFile?: string +} + +export const newFileMockStateStore = async ( + options: MockStateOptions = {}, +): Promise => { + const initialState = options.initialState + ? clone(options.initialState) + : (await readPersistedState(options.stateFile)) ?? seedState() + + return newMswDataStateStore({ + initialState, + persist: (state) => persistState(options.stateFile, state), + }) +} + +const persistState = async (stateFile: string | undefined, state: MockState) => { + if (!stateFile) { + return + } + + await mkdir(path.dirname(stateFile), { recursive: true }) + await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8') +} + +const readPersistedState = async (stateFile: string | undefined) => { + if (!stateFile) { + return null + } + + try { + const result = zMockState.safeParse(JSON.parse(await readFile(stateFile, 'utf8'))) + + return result.success ? result.data : null + } catch (error) { + if (isNodeFileError(error) && error.code === 'ENOENT') { + return null + } + + console.warn(`Ignoring invalid mock state at ${stateFile}. Resetting to seed state.`) + + return null + } +} + +const isNodeFileError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error diff --git a/skills/mockapi/assets/templates/mock-server/src/server.ts b/skills/mockapi/assets/templates/mock-server/src/server.ts index 42b8038..90e354f 100644 --- a/skills/mockapi/assets/templates/mock-server/src/server.ts +++ b/skills/mockapi/assets/templates/mock-server/src/server.ts @@ -6,8 +6,8 @@ import { readMockServerConfig } from './config.ts' import { newMockApiControllers } from './controllers.ts' const config = readMockServerConfig() -const app = newMockApiApp({ - controllers: newMockApiControllers({ +const app = await newMockApiApp({ + controllers: await newMockApiControllers({ stateFile: config.stateFile, }), }) diff --git a/skills/mockapi/reference/generate.md b/skills/mockapi/reference/generate.md index a90e20a..71ce411 100644 --- a/skills/mockapi/reference/generate.md +++ b/skills/mockapi/reference/generate.md @@ -129,15 +129,15 @@ nvm-managed Node installations and corepack for pnpm/Yarn. 7. After codegen succeeds, read `reference/mock-server-structure.md`, `reference/mock-server-examples.md`, `reference/seed-data.md`, `reference/testing.md`, `reference/mock-server-quality.md`, - `.mockapi/behavior.md`, generated `src/generated/**`, + `.mockapi/behavior.md`, generated `src/generated/**`, `src/dependencies.ts`, `src/controllers.ts`, and `src/features/**`. Create feature-local `service.ts` when behavior needs orchestration. Create feature-local `repository.ts` for every completed feature that owns non-infrastructure state slices; `idCounters` alone does not need a repository. Replace generated product seed stub defaults unless `.mockapi/profile.toml` sets `state.seed = false`. Extend - `MockApiDependencies` in `src/controllers.ts`, instantiate - repositories/services from the shared `MockStateRepository`, and pass + `MockApiDependencies` in `src/dependencies.ts`, instantiate + repositories/services from the shared `MockStateStore`, and pass dependencies into operation controllers through `deps`. Keep feature seed data in `src/features//seed.ts`; generated `src/generated/mock-admin/state/seed.ts` will aggregate feature seed @@ -151,10 +151,12 @@ nvm-managed Node installations and corepack for pnpm/Yarn. mutations such as `create`/`update`/`markDeleted`/`restore`, and avoid service-facing `setAll`, `setItems`, or `replaceAll` APIs. Do not create a single shared product repository. Services may use - `MockStateRepository` directly for transactions, mock clock helpers, + `MockStateStore` directly for transactions, mock clock helpers, `idCounters`, and cross-repository coordination only; product state reads and writes go through feature repositories. Read-only composite features should - consume other feature repositories instead of raw state slices. Keep operation + consume other feature repositories instead of raw state slices. Wrap mutating + workflows in `await stateStore.transaction(async () => ...)` and await + repository mutations inside the transaction. Keep operation controllers thin; do not put data-access logic there. Replace controller TODOs from the matching behavior anchors. Use counter IDs with `idCounters` and `newIdAllocator` by default for create operations; slug-style IDs require @@ -202,11 +204,13 @@ incomplete. - OpenAPI runtime/config files and `src/generated/mock-admin/state/**` may be overwritten. - Template scaffolds such as `src/app.ts`, `src/server.ts`, - `src/controllers.ts`, and `src/lib/**` are create-only. -- `src/controllers.ts` is the LLM-owned composition root for DI and route - controller wiring. If the profile API set changes after initial generation, - update `src/app.ts` registrations manually as part of the same LLM-owned - wiring pass. + `src/controllers.ts`, `src/dependencies.ts`, and `src/lib/**` are + create-only. +- `src/dependencies.ts` is the LLM-owned composition root for feature + repositories and services. `src/controllers.ts` is the LLM-owned route + controller aggregation and admin wiring file. If the profile API set changes + after initial generation, update `src/app.ts` registrations manually as part + of the same LLM-owned wiring pass. - `src/features/**` is reviewable behavior. Generation creates missing operation controller TODO adapters and create-only seed stubs for owned state slices. Feature services and repositories are LLM-owned and created only when diff --git a/skills/mockapi/reference/generated-server.md b/skills/mockapi/reference/generated-server.md index 5d4e3e3..a0d6ef6 100644 --- a/skills/mockapi/reference/generated-server.md +++ b/skills/mockapi/reference/generated-server.md @@ -29,7 +29,9 @@ Create-only scaffold: - `src/app.ts` - `src/server.ts` - `src/controllers.ts` +- `src/dependencies.ts` - `src/lib/**` +- `src/browser.ts` Reviewable behavior: @@ -42,9 +44,9 @@ endpoint starters are written to: - `src/features//controllers/.ts` - `src/features//seed.ts` for features that own state slices -The controller file is a thin typed TODO adapter. Root `src/controllers.ts` is -LLM-owned and must be updated when dependencies or operation wiring change after -initial generation. +The controller file is a thin typed TODO adapter. Root `src/controllers.ts` and +`src/dependencies.ts` are LLM-owned and must be updated when dependencies, +services, or operation wiring change after initial generation. `src/generated/mock-admin/state/**` is generated admin infrastructure. Do not edit it by hand; put seed data in feature seed files and behavior in feature @@ -64,8 +66,9 @@ non-infrastructure product state slices must have a feature repository: - `src/features//seed.ts`: create-only seed stub for `features[].stateSlices`; replace empty product defaults according to `reference/seed-data.md`. -- `src/controllers.ts`: composition root that creates repositories and passes - them through `MockApiDependencies`. +- `src/dependencies.ts`: composition root that creates repositories/services + from the shared `MockStateStore` and returns `MockApiDependencies`. +- `src/controllers.ts`: route-controller aggregation and admin wiring. Keep product/domain helpers near their owning feature, for example `src/features/notes/noteDetails.ts` or @@ -74,16 +77,18 @@ product-agnostic infrastructure helpers such as clone, errors, request context, IDs, soft delete, and sorting. Do not add product-domain modules such as `src/lib/notes.ts`, `src/lib/deckStats.ts`, or `src/lib/tree.ts`. -`MockStateRepository` is the low-level in-memory state store. Feature -repositories sit above it as domain slice facades. They use `getSlice` and -`setSlice` to expose methods such as `all`, `visible`, `find`, `require`, -`create`, `update`, `markDeleted`, `restore`, `remove`, and feature-specific -selectors. Use `find` or `require` instead of `byId`; avoid service-facing -`setAll`, `setItems`, or `replaceAll` feature APIs. Keep canonical state as -arrays or singleton slices by default, not maps. +`MockStateStore` is the low-level state store in `src/lib/stateStore.ts`. It +uses `@msw/data` collections for entity array slices with an `idField`, and +keeps singleton/meta slices as typed snapshot values. Feature repositories sit +above it as domain slice facades. For entity slices, use `findEntities`, +`findEntity`, `createEntity`, `updateEntity`, and `deleteEntity`; for singleton +slices, use `getSlice` and `setSlice`. Expose methods such as `all`, `visible`, +`find`, `require`, `create`, `update`, `markDeleted`, `restore`, `remove`, and +feature-specific selectors. Use `find` or `require` instead of `byId`; avoid +service-facing `setAll`, `setItems`, or `replaceAll` feature APIs. Feature services own orchestration. They must use feature repositories for -domain resource reads and writes, but may use `MockStateRepository` directly for +domain resource reads and writes, but may use `MockStateStore` directly for transaction boundaries, mock clock helpers, ID counters, and cross-repository coordination. Read-only composite services should consume other feature repositories instead of raw state slices. Services should call named repository @@ -97,15 +102,15 @@ counter, and read-only composite examples, use `reference/mock-server-examples.m Example: ```ts -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' import type { WorkspaceRecord } from '../../generated/mock-admin/contract/index.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { visible } from '../../lib/softDelete.ts' export class WorkspacesRepository { - constructor(private readonly stateStore: MockStateRepository) {} + constructor(private readonly stateStore: MockStateStore) {} all() { - return this.stateStore.getSlice('workspaces') + return this.stateStore.findEntities('workspaces') } visible() { @@ -113,27 +118,26 @@ export class WorkspacesRepository { } find(workspaceId: string) { - return this.all().find((workspace) => workspace.id === workspaceId) + return this.stateStore.findEntity('workspaces', workspaceId) } - create(workspace: WorkspaceRecord) { - this.stateStore.setSlice('workspaces', [workspace, ...this.all()]) + async create(workspace: WorkspaceRecord) { + return this.stateStore.createEntity('workspaces', workspace, { prepend: true }) } } ``` -Wire repositories in `src/controllers.ts`: +Wire repositories and services in `src/dependencies.ts`: ```ts export type MockApiDependencies = { - stateRepository: MockStateRepository + stateStore: MockStateStore workspacesRepository: WorkspacesRepository } -const stateRepository = new MockStateRepository(options) -const workspacesRepository = new WorkspacesRepository(stateRepository) +const workspacesRepository = new WorkspacesRepository(stateStore) const deps: MockApiDependencies = { - stateRepository, + stateStore, workspacesRepository, } ``` @@ -207,6 +211,11 @@ package by default. Startup logs include the absolute path as Set `MOCK_API_STATE_FILE` to use a different snapshot file; relative override paths resolve from the generated package root. +Generated packages also expose `./browser` from `package.json`. Use +`@local/mock-server/browser` from UI, Storybook, or browser tests to create a +`newBrowserMockStateStore({ storageKey })`, inspect `zMockState`, or build +browser-local service dependencies without importing Node filesystem code. + `openapi/admin.source.yaml` is generated from `.mockapi/profile.toml` and may contain external product schema refs. `scripts/codegen-admin-openapi.ts` runs before `openapi-ts`, copies the transitive state schema graph into diff --git a/skills/mockapi/reference/mock-server-examples.md b/skills/mockapi/reference/mock-server-examples.md index 2309aa6..1bd6667 100644 --- a/skills/mockapi/reference/mock-server-examples.md +++ b/skills/mockapi/reference/mock-server-examples.md @@ -42,16 +42,16 @@ inside the repository. ## Composition Root -Create dependencies once in `src/controllers.ts`: state store first, then -repositories, derived resolvers, services, and operation controllers. +Create dependencies once in `src/dependencies.ts`: state store first, then +repositories, derived resolvers, and services. `src/controllers.ts` creates the +store, calls `newMockApiDependencies(stateStore)`, and aggregates operation +controllers. ```ts -const stateRepository = new MockStateRepository(options) - -const workspacesRepository = new WorkspaceRepository(stateRepository) -const foldersRepository = new FolderRepository(stateRepository) -const decksRepository = new DeckRepository(stateRepository) -const trashRepository = new TrashRepository(stateRepository) +const workspacesRepository = new WorkspaceRepository(stateStore) +const foldersRepository = new FolderRepository(stateStore) +const decksRepository = new DeckRepository(stateStore) +const trashRepository = new TrashRepository(stateStore) const locationPathResolver = new LocationPathResolver( decksRepository, @@ -62,14 +62,14 @@ const locationPathResolver = new LocationPathResolver( const folderService = new FolderService( foldersRepository, locationPathResolver, - stateRepository, + stateStore, workspacesRepository, trashRepository, ) const deps: MockApiDependencies = { folderService, - stateRepository, + stateStore, } ``` @@ -85,18 +85,18 @@ entire array, modify it, and write it back. ```ts import { notFound } from '../../generated/product-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' import type { MockState } from '../../generated/mock-admin/contract/index.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { sortByPreference, type SortPreference } from '../../lib/sort.ts' import { visible } from '../../lib/softDelete.ts' type FolderRecord = MockState['folders'][number] export class FolderRepository { - constructor(private readonly stateStore: MockStateRepository) {} + constructor(private readonly stateStore: MockStateStore) {} all() { - return this.stateStore.getSlice('folders') + return this.stateStore.findEntities('folders') } visible() { @@ -104,7 +104,7 @@ export class FolderRepository { } find(folderId: string) { - return this.all().find((folder) => folder.id === folderId) + return this.stateStore.findEntity('folders', folderId) } require(folderId: string, options: { includeDeleted?: boolean } = {}) { @@ -118,37 +118,31 @@ export class FolderRepository { return folder } - create(folder: FolderRecord) { - this.stateStore.setSlice('folders', [folder, ...this.all()]) + async create(folder: FolderRecord) { + return this.stateStore.createEntity('folders', folder, { prepend: true }) } - update(folderId: string, updater: (folder: FolderRecord) => FolderRecord) { - let next: FolderRecord | undefined - - this.stateStore.setSlice('folders', this.all().map((folder) => { - if (folder.id !== folderId) { - return folder - } - - next = updater(folder) - - return next - })) - - return next ?? this.require(folderId) + async update(folderId: string, updater: (folder: FolderRecord) => FolderRecord) { + return ( + await this.stateStore.updateEntity('folders', folderId, updater) + ) ?? this.require(folderId, { includeDeleted: true }) } - markDeleted(folderId: string, deletedAt: string) { + async markDeleted(folderId: string, deletedAt: string) { return this.update(folderId, (folder) => ({ ...folder, deletedAt })) } - restore(folderId: string) { + async restore(folderId: string) { return this.update(folderId, (folder) => { const { deletedAt: _deletedAt, ...restored } = folder return restored }) } + async remove(folderId: string) { + return this.stateStore.deleteEntity('folders', folderId) + } + listByParent(workspaceId: string, parentId: string, sort?: SortPreference) { const folders = this.visible().filter( (folder) => folder.workspaceId === workspaceId && folder.parentId === parentId, @@ -170,12 +164,12 @@ raw `setItems` API: ```ts import { notFound } from '../../generated/product-api/mock-runtime.ts' import type { MockState } from '../../generated/mock-admin/contract/index.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' type TrashState = MockState['trash'] export class TrashRepository { - constructor(private readonly stateStore: MockStateRepository) {} + constructor(private readonly stateStore: MockStateStore) {} get() { return this.stateStore.getSlice('trash') @@ -191,26 +185,30 @@ export class TrashRepository { return item } - addItem(item: TrashState['items'][number]) { + async addItem(item: TrashState['items'][number]) { const trash = this.get() - this.stateStore.setSlice('trash', { + await this.stateStore.setSlice('trash', { ...trash, items: [item, ...trash.items.filter((candidate) => candidate.id !== item.id)], }) + + return this.get() } - removeItem(itemId: string) { + async removeItem(itemId: string) { const trash = this.get() - this.stateStore.setSlice('trash', { + await this.stateStore.setSlice('trash', { ...trash, items: trash.items.filter((item) => item.id !== itemId), }) + + return this.get() } - empty(lastEmptiedAt: string) { - this.stateStore.setSlice('trash', { + async empty(lastEmptiedAt: string) { + await this.stateStore.setSlice('trash', { items: [], lastEmptiedAt, }) @@ -233,7 +231,7 @@ import type { } from '../../generated/product-api/contract/index.ts' import { clone } from '../../lib/clone.ts' import { newIdAllocator } from '../../lib/ids.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import type { LocationPathResolver } from '../location-path/resolver.ts' import type { TrashRepository } from '../trash/repository.ts' import type { WorkspaceRepository } from '../workspaces/repository.ts' @@ -243,15 +241,15 @@ export class FolderService { constructor( private readonly folders: FolderRepository, private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, + private readonly stateStore: MockStateStore, private readonly workspaces: WorkspaceRepository, private readonly trash: TrashRepository, ) {} - create(draft: FolderDraft): Folder { + async create(draft: FolderDraft): Promise { const workspaceId = this.workspaceIdForParent(draft.parentId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const folder: Folder = { ...draft, @@ -260,49 +258,49 @@ export class FolderService { workspaceId, } - this.folders.create(folder) - this.workspaces.touch(workspaceId, this.stateStore.now()) + await this.folders.create(folder) + await this.workspaces.touch(workspaceId, this.stateStore.now()) return clone(folder) }) } - update(folderId: string, draft: FolderDraft): Folder { + async update(folderId: string, draft: FolderDraft): Promise { const current = this.folders.require(folderId) const workspaceId = this.workspaceIdForParent(draft.parentId) - return this.stateStore.transaction(() => { - const folder = this.folders.update(folderId, (existing) => ({ + return this.stateStore.transaction(async () => { + const folder = await this.folders.update(folderId, (existing) => ({ ...existing, ...draft, updatedAt: this.stateStore.now(), workspaceId, })) - this.workspaces.touch(workspaceId, this.stateStore.now()) + await this.workspaces.touch(workspaceId, this.stateStore.now()) if (current.workspaceId !== workspaceId) { - this.workspaces.touch(current.workspaceId, this.stateStore.now()) + await this.workspaces.touch(current.workspaceId, this.stateStore.now()) } return clone(folder) }) } - delete(folderId: string) { + async delete(folderId: string) { const folder = this.folders.require(folderId) - this.stateStore.transaction(() => { + await this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() - this.folders.markDeleted(folderId, deletedAt) - this.trash.addItem({ + await this.folders.markDeleted(folderId, deletedAt) + await this.trash.addItem({ deletedAt, id: folder.id, kind: 'folder', locationPath: this.paths.folderContainerPathSegments(folder), title: folder.name, }) - this.workspaces.touch(folder.workspaceId, deletedAt) + await this.workspaces.touch(folder.workspaceId, deletedAt) }) } @@ -330,7 +328,7 @@ this.folders.setAll([...folders, folder]) Prefer a named repository mutation: ```ts -this.folders.create(folder) +await this.folders.create(folder) ``` ## Soft Delete and Trash Flow @@ -340,33 +338,33 @@ restore operations should go through the trash feature and call feature repository `restore` methods. ```ts -delete(folderId: string) { +async delete(folderId: string) { const folder = this.folders.require(folderId) - this.stateStore.transaction(() => { + await this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() - this.folders.markDeleted(folderId, deletedAt) - this.trash.addItem({ + await this.folders.markDeleted(folderId, deletedAt) + await this.trash.addItem({ deletedAt, id: folder.id, kind: 'folder', locationPath: this.paths.folderContainerPathSegments(folder), title: folder.name, }) - this.workspaces.touch(folder.workspaceId, deletedAt) + await this.workspaces.touch(folder.workspaceId, deletedAt) }) } -restore(itemId: string) { +async restore(itemId: string) { const item = this.trash.requireItem(itemId) - this.stateStore.transaction(() => { + await this.stateStore.transaction(async () => { if (item.kind === 'folder') { - this.folders.restore(itemId) + await this.folders.restore(itemId) } - this.trash.removeItem(itemId) + await this.trash.removeItem(itemId) }) } ``` @@ -379,7 +377,7 @@ For example, when notes are created or deleted, update the owning deck through ```ts export class WorkspaceRepository { - touch(workspaceId: string, updatedAt: string) { + async touch(workspaceId: string, updatedAt: string) { return this.update(workspaceId, (workspace) => ({ ...workspace, updatedAt, @@ -388,7 +386,7 @@ export class WorkspaceRepository { } export class DeckRepository { - bumpTotalNotes(deckId: string, amount: number, updatedAt: string) { + async bumpTotalNotes(deckId: string, amount: number, updatedAt: string) { return this.update(deckId, (deck) => ({ ...deck, totalNotes: Math.max(0, deck.totalNotes + amount), @@ -398,15 +396,15 @@ export class DeckRepository { } export class NoteService { - create(draft: NoteDraft) { + async create(draft: NoteDraft) { const deck = this.decks.require(draft.deckId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const note = this.buildNote(draft) - this.notes.create(note) - this.decks.bumpTotalNotes(draft.deckId, 1, this.stateStore.now()) - this.workspaces.touch(deck.workspaceId, this.stateStore.now()) + await this.notes.create(note) + await this.decks.bumpTotalNotes(draft.deckId, 1, this.stateStore.now()) + await this.workspaces.touch(deck.workspaceId, this.stateStore.now()) return clone({ id: note.id, deckId: note.deckId }) }) diff --git a/skills/mockapi/reference/mock-server-quality.md b/skills/mockapi/reference/mock-server-quality.md index 4b43de4..d051fca 100644 --- a/skills/mockapi/reference/mock-server-quality.md +++ b/skills/mockapi/reference/mock-server-quality.md @@ -43,14 +43,15 @@ is validated by `validate_profile.py`. ## Architecture Signals Keep state access feature-local. Use feature repositories for reads, writes, -and selectors over owned slices. Use `MockStateRepository` directly only for +and selectors over owned slices. Use `MockStateStore` directly only for transactions, mock clock, ID counters, admin-like snapshot operations, or cross-feature orchestration. Every completed feature that owns non-infrastructure product state slices must have `src/features//repository.ts`. `idCounters` is infrastructure and does not require a feature repository by itself. Feature services and operation -controllers must not call `getSlice` or `setSlice` on product state slices +controllers must not call `getSlice`, `setSlice`, `findEntities`, `findEntity`, +`createEntity`, `updateEntity`, or `deleteEntity` on product state slices directly; keep those calls inside feature repositories. Repositories should expose named mutations and selectors. Feature services diff --git a/skills/mockapi/reference/mock-server-structure.md b/skills/mockapi/reference/mock-server-structure.md index 9fcf7fe..9b23372 100644 --- a/skills/mockapi/reference/mock-server-structure.md +++ b/skills/mockapi/reference/mock-server-structure.md @@ -10,6 +10,7 @@ For compact implementation examples, read `reference/mock-server-examples.md`. src/ app.ts controllers.ts + dependencies.ts features/ / service.ts @@ -19,26 +20,32 @@ src/ generated/ mock-admin/state/ controller.ts - repository.ts seed.ts service.ts lib/ + stateStore.ts + nodeStateStore.ts + browserStateStore.ts ``` `src/lib` is for product-agnostic infrastructure helpers only. Keep generated -template-style utilities there, such as clone, errors, request context, IDs, -soft delete, and sorting. Product/domain helpers belong under +template-style utilities there, such as the `@msw/data`-backed state store, +Node/browser persistence adapters, clone, errors, request context, IDs, soft +delete, and sorting. Product/domain helpers belong under `src/features//...`; for cross-feature derived behavior, create a small named feature service or resolver rather than a broad `src/lib/tree.ts` or `src/lib/domain.ts`. -`src/generated/mock-admin/state/repository.ts` is the low-level in-memory state -store. It owns snapshot persistence, reset, clock helpers, transactions, and -typed `getSlice` / `setSlice` access. +`src/lib/stateStore.ts` is the low-level in-memory state store. It uses +`@msw/data` collections for entity array slices with an `idField`, owns reset, +clock helpers, transactions, and typed entity/slice access. `nodeStateStore.ts` +hydrates/persists JSON snapshots on disk; `browserStateStore.ts` hydrates and +persists snapshots in `localStorage`. -`src/controllers.ts` is the composition root. Instantiate feature repositories -there from the shared `MockStateRepository`, add them to `MockApiDependencies`, -and pass `deps` into operation controller and service factories. +`src/dependencies.ts` is the feature composition root. Instantiate feature +repositories there from the shared `MockStateStore`, add services to +`MockApiDependencies`, and pass `deps` into operation controller factories from +`src/controllers.ts`. `src/generated/mock-admin/state/**` is generated admin infrastructure. Do not edit those files by hand; use feature modules for behavior and feature seed @@ -97,11 +104,13 @@ state slice to exactly one feature in `profile.toml`. See ## Behavior Implementation Feature services orchestrate behavior. Use feature repositories for domain -reads and writes. Use `MockStateRepository` directly only for cross-feature +reads and writes. Use `MockStateStore` directly only for cross-feature transactions, mock clock helpers, ID counters, snapshot-level operations, or other infrastructure behavior. Read-only composite services, such as search, -should consume other feature repositories instead of calling `getSlice` or -`setSlice` on product slices directly. +should consume other feature repositories instead of calling state-store methods +on product slices directly. Wrap mutating workflows in +`await stateStore.transaction(async () => ...)` so snapshot persistence runs +after the complete workflow. Repositories own collection mutation. Services should call named repository methods such as `create`, `update`, `markDeleted`, `restore`, `remove`, diff --git a/skills/mockapi/reference/testing.md b/skills/mockapi/reference/testing.md index 79c9fd0..d2e2a3a 100644 --- a/skills/mockapi/reference/testing.md +++ b/skills/mockapi/reference/testing.md @@ -11,6 +11,8 @@ Write unit tests beside the feature source file they cover: - `src/features//service.test.ts` for `service.ts` - `src/features//repository.test.ts` for `repository.ts` - `src/features//.test.ts` for feature-local domain helpers +- `src/lib/stateStore.test.ts` when changing the generated `@msw/data` store or + persistence adapters Unit-test completed LLM-owned behavior, not deterministic generated scaffold. Do not add tests for generated TODO adapters or thin operation controllers by diff --git a/skills/mockapi/scripts/mockapi_runtime/files.py b/skills/mockapi/scripts/mockapi_runtime/files.py index 59b839e..98bb9c4 100644 --- a/skills/mockapi/scripts/mockapi_runtime/files.py +++ b/skills/mockapi/scripts/mockapi_runtime/files.py @@ -12,7 +12,6 @@ "scripts/codegen-admin-openapi.ts", "scripts/lib/mockRuntimeCodegen.ts", "src/generated/mock-admin/state/controller.ts", - "src/generated/mock-admin/state/repository.ts", "src/generated/mock-admin/state/service.ts", } ) diff --git a/skills/mockapi/scripts/mockapi_runtime/quality.py b/skills/mockapi/scripts/mockapi_runtime/quality.py index c9b961a..4a5b013 100644 --- a/skills/mockapi/scripts/mockapi_runtime/quality.py +++ b/skills/mockapi/scripts/mockapi_runtime/quality.py @@ -18,6 +18,9 @@ AS_ANY_PATTERN = re.compile(r"\bas\s+any\b") SLUG_ID_SOURCE_PATTERN = re.compile(r"\b(?:slugify|uniqueSlug)\b", re.IGNORECASE) DIRECT_SLICE_ACCESS_PATTERN = re.compile(r"\.(?:getSlice|setSlice)\(\s*(['\"])(?P[^'\"]+)\1") +DIRECT_ENTITY_ACCESS_PATTERN = re.compile( + r"\.(?:findEntities|findEntity|createEntity|updateEntity|deleteEntity)\(\s*(['\"])(?P[^'\"]+)\1" +) PICK_MOCK_STATE_PATTERN = re.compile(r"Pick<\s*MockState\s*,\s*(?P[^>]+)>", re.DOTALL) STRING_LITERAL_PATTERN = re.compile(r"['\"](?P[^'\"]+)['\"]") EMPTY_LITERAL_CONTENT_PATTERN = r"(?:\s|//[^\n\r]*(?:\r?\n|$)|/\*.*?\*/)*" @@ -459,6 +462,11 @@ def _check_direct_slice_access(context: QualityScanContext, errors: list[Diagnos for match in DIRECT_SLICE_ACCESS_PATTERN.finditer(scanned_file.text) if match.group("slice") not in INFRASTRUCTURE_STATE_SLICES } + | { + match.group("slice") + for match in DIRECT_ENTITY_ACCESS_PATTERN.finditer(scanned_file.text) + if match.group("slice") not in INFRASTRUCTURE_STATE_SLICES + } ) if slices: errors.append( diff --git a/skills/mockapi/scripts/mockapi_runtime/render_project.py b/skills/mockapi/scripts/mockapi_runtime/render_project.py index 78e8d16..bfdf9e6 100644 --- a/skills/mockapi/scripts/mockapi_runtime/render_project.py +++ b/skills/mockapi/scripts/mockapi_runtime/render_project.py @@ -119,6 +119,10 @@ def yaml_key(value: str) -> str: return value if re.match(r"^[A-Za-z_][A-Za-z0-9_-]*$", value) else quote(value) +def ts_property_key(value: str) -> str: + return value if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value) else quote(value) + + def indent_block(text: str, spaces: int) -> str: prefix = " " * spaces return "\n".join(f"{prefix}{line}" if line else line for line in text.splitlines()) @@ -301,13 +305,13 @@ def render_app(profile: Profile, template_service: TemplateService) -> str: registrations = "\n".join( [ """ registerGeneratedMockRoutes(app, { - controllers, + controllers: mockControllers, runtime: adminRuntime, })""", *[ f""" registerGeneratedMockRoutes(app, {{ basePath: {"basePath" if uses_base_path_option else quote(api.basePath or "")}, - controllers, + controllers: mockControllers, runtime: {to_camel_case(api.name)}Runtime, }})""" for api in profile.apis @@ -418,6 +422,95 @@ def render_state_seed(profile: Profile, template_service: TemplateService) -> st ) +def entity_state_slices(profile: Profile) -> list[ProfileStateSlice]: + return [ + state_slice + for state_slice in profile.state.slices + if state_slice.array is not False + and state_slice.idField + and schema_ref_parts(profile, state_slice) is not None + ] + + +def render_state_store(profile: Profile, template_service: TemplateService) -> str: + entity_slices = entity_state_slices(profile) + entity_names = [state_slice.name for state_slice in entity_slices] + meta_names = [state_slice.name for state_slice in profile.state.slices if state_slice.name not in set(entity_names)] + + zod_import = "" + if entity_slices: + zod_names = ",\n ".join(f"z{state_type_name(state_slice)}" for state_slice in entity_slices) + zod_import = f"""import {{ + {zod_names}, +}} from '../generated/mock-admin/contract/zod.gen.ts' +""" + + entity_union = " | ".join(quote(name) for name in entity_names) if entity_names else "never" + entity_record_map = ( + "\n".join( + f" {ts_property_key(state_slice.name)}: MockState[{quote(state_slice.name)}][number]" + for state_slice in entity_slices + ) + if entity_slices + else " [key: string]: never" + ) + entity_keys = ( + "\n".join(f" {quote(name)}," for name in entity_names) + if entity_names + else "" + ) + entity_id_fields = ( + "\n".join(f" {ts_property_key(state_slice.name)}: {quote(state_slice.idField or 'id')}," for state_slice in entity_slices) + if entity_slices + else "" + ) + entity_collections = ( + "\n".join( + f" {ts_property_key(state_slice.name)}: new Collection({{ schema: z{state_type_name(state_slice)} }})," + for state_slice in entity_slices + ) + if entity_slices + else "" + ) + meta_properties = "\n".join( + [ + " schemaVersion: state.schemaVersion,", + " clock: clone(state.clock),", + *[f" {ts_property_key(name)}: clone(state[{quote(name)}])," for name in meta_names], + ] + ) + snapshot_properties = "\n".join( + [ + " schemaVersion: this.meta.schemaVersion,", + " clock: clone(this.meta.clock),", + *[ + f" {ts_property_key(state_slice.name)}: " + + ( + f"this.entitySnapshot({quote(state_slice.name)})," + if state_slice.name in entity_names + else f"clone(this.meta[{quote(state_slice.name)}])," + ) + for state_slice in profile.state.slices + ], + ] + ) + + return template_service.render( + "state-store.ts.tpl", + { + "ENTITY_COLLECTIONS": entity_collections, + "ENTITY_ID_FIELDS": entity_id_fields, + "ENTITY_KEYS": entity_keys, + "ENTITY_RECORD_MAP": entity_record_map, + "ENTITY_UNION": entity_union, + "META_PROPERTIES": meta_properties, + "SNAPSHOT_PROPERTIES": snapshot_properties, + "STARTER_HEADER": STARTER_HEADER, + "ZOD_IMPORT": zod_import, + }, + ) + + class ProjectRenderService: def __init__( self, @@ -450,5 +543,6 @@ def planned_writes(self, context: GenerateContext) -> list[PlannedWrite]: ), planned_write(out_root / "src/app.ts", render_app(profile, self.template_service), overwrite=False), planned_write(out_root / "src/controllers.ts", render_controllers(context, self.template_service), overwrite=False), + planned_write(out_root / "src/lib/stateStore.ts", render_state_store(profile, self.template_service), overwrite=False), planned_write(out_root / "src/generated/mock-admin/state/seed.ts", render_state_seed(profile, self.template_service), overwrite=True), ] diff --git a/skills/mockapi/scripts/mockapi_runtime/templates/app.ts.tpl b/skills/mockapi/scripts/mockapi_runtime/templates/app.ts.tpl index 8960006..29f660e 100644 --- a/skills/mockapi/scripts/mockapi_runtime/templates/app.ts.tpl +++ b/skills/mockapi/scripts/mockapi_runtime/templates/app.ts.tpl @@ -18,11 +18,12 @@ export type NewMockApiAppOptions = { controllers?: MockApiControllers } -export const newMockApiApp = ({ +export const newMockApiApp = async ({ basePath = {{DEFAULT_BASE_PATH}}, - controllers = newMemoryMockApiControllers(), + controllers, }: NewMockApiAppOptions = {}) => { const app = new Hono() + const mockControllers = controllers ?? await newMemoryMockApiControllers() app.use('*', cors()) {{ROUTE_REGISTRATIONS}} diff --git a/skills/mockapi/scripts/mockapi_runtime/templates/controllers.ts.tpl b/skills/mockapi/scripts/mockapi_runtime/templates/controllers.ts.tpl index 2f9201e..bb1b1f0 100644 --- a/skills/mockapi/scripts/mockapi_runtime/templates/controllers.ts.tpl +++ b/skills/mockapi/scripts/mockapi_runtime/templates/controllers.ts.tpl @@ -2,26 +2,29 @@ {{PRODUCT_IMPORTS}} import type { GeneratedMockControllers as AdminGeneratedMockControllers } from './generated/mock-admin/mock-runtime.ts' import { newAdminStateController } from './generated/mock-admin/state/controller.ts' -import { MockStateRepository, type MockStateOptions } from './generated/mock-admin/state/repository.ts' import { AdminStateService } from './generated/mock-admin/state/service.ts' import { seedState } from './generated/mock-admin/state/seed.ts' import type { MockState } from './generated/mock-admin/contract/index.ts' +import { + newMockApiDependencies, + type MockApiDependencies, +} from './dependencies.ts' +import { + newFileMockStateStore, + type MockStateOptions, +} from './lib/nodeStateStore.ts' {{OPERATION_IMPORTS}} export type ProductMockControllers = {{PRODUCT_TYPES}} export type MockApiControllers = ProductMockControllers & AdminGeneratedMockControllers -export type MockApiDependencies = { - stateRepository: MockStateRepository -} +export type { MockApiDependencies } -export const newMockApiControllers = ( +export const newMockApiControllers = async ( options: MockStateOptions = {}, -): MockApiControllers => { - const stateRepository = new MockStateRepository(options) - const deps: MockApiDependencies = { - stateRepository, - } - const adminStateService = new AdminStateService(stateRepository, {{OPERATION_COUNT}}) +): Promise => { + const stateStore = await newFileMockStateStore(options) + const deps = newMockApiDependencies(stateStore) + const adminStateService = new AdminStateService(stateStore, {{OPERATION_COUNT}}) return { {{CONTROLLER_SPREADS}} @@ -29,6 +32,6 @@ export const newMockApiControllers = ( } as MockApiControllers } -export const newMemoryMockApiControllers = ( +export const newMemoryMockApiControllers = async ( initialState: MockState = seedState(), -): MockApiControllers => newMockApiControllers({ initialState }) +): Promise => newMockApiControllers({ initialState }) diff --git a/skills/mockapi/scripts/mockapi_runtime/templates/state-store.ts.tpl b/skills/mockapi/scripts/mockapi_runtime/templates/state-store.ts.tpl new file mode 100644 index 0000000..86f6e5f --- /dev/null +++ b/skills/mockapi/scripts/mockapi_runtime/templates/state-store.ts.tpl @@ -0,0 +1,303 @@ +{{STARTER_HEADER}}import { Collection } from '@msw/data' + +import { clone } from './clone.ts' +import type { + MockClock, + MockState, +} from '../generated/mock-admin/contract/index.ts' +{{ZOD_IMPORT}}import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type EntitySliceKey = {{ENTITY_UNION}} + +type EntityRecord = + EntityRecordMap[TKey] + +type EntityRecordMap = { +{{ENTITY_RECORD_MAP}} +} + +type EntityCollection = { + all: () => TRecord[] + clear: () => void + create: (record: TRecord) => Promise + delete: (record: TRecord) => TRecord | undefined + findFirst: ( + predicate?: (query: { where: (predicate: unknown) => unknown }) => unknown, + ) => TRecord | undefined + update: ( + record: TRecord, + options: { data: (draft: TRecord) => void }, + ) => Promise +} + +type EntityCollections = ReturnType +type MetaState = Omit + +export type MockStatePersist = (state: MockState) => Promise | void + +export type MswDataStateStoreOptions = { + initialState?: MockState + persist?: MockStatePersist +} + +export interface MockStateStore { + createEntity( + key: TKey, + record: EntityRecord, + options?: { prepend?: boolean }, + ): Promise> + deleteEntity( + key: TKey, + id: string, + ): Promise | undefined> + daysFromNow(days: number): string + findEntity( + key: TKey, + id: string, + ): EntityRecord | undefined + findEntities( + key: TKey, + predicate?: (record: EntityRecord) => boolean, + ): Array> + getSlice(key: TKey): MockState[TKey] + now(): string + replace(state: MockState): Promise + reset(): Promise + setClock(now: string): Promise + setSlice(key: TKey, value: MockState[TKey]): Promise + snapshot(): MockState + transaction(callback: () => Promise | T): Promise + updateEntity( + key: TKey, + id: string, + updater: (record: EntityRecord) => EntityRecord, + ): Promise | undefined> +} + +const dayMs = 24 * 60 * 60 * 1000 + +const entitySliceKeys = [ +{{ENTITY_KEYS}} +] as const satisfies readonly EntitySliceKey[] + +const entityIdFields = { +{{ENTITY_ID_FIELDS}} +} as const satisfies Record + +const newEntityCollections = () => ({ +{{ENTITY_COLLECTIONS}} +}) + +const isEntitySliceKey = (key: keyof MockState): key is EntitySliceKey => + entitySliceKeys.includes(key as EntitySliceKey) + +const metaFromState = (state: MockState): MetaState => ({ +{{META_PROPERTIES}} +}) + +const replaceDraft = (draft: TRecord, next: TRecord) => { + const draftRecord = draft as Record + const nextRecord = next as Record + + for (const key of Object.keys(draftRecord)) { + if (!(key in nextRecord)) { + delete draftRecord[key] + } + } + + Object.assign(draftRecord, nextRecord) +} + +export class MswDataStateStore implements MockStateStore { + private readonly collections: EntityCollections = newEntityCollections() + private readonly persistState: MockStatePersist | undefined + private meta: MetaState = metaFromState(seedState()) + + constructor(options: Pick = {}) { + this.persistState = options.persist + } + + async reset() { + return this.replace(seedState()) + } + + async replace(state: MockState) { + await this.replaceState(state) + await this.persist() + + return this.snapshot() + } + + async setClock(now: string) { + this.meta.clock = { now } + await this.persist() + + return clone(this.meta.clock) + } + + getSlice(key: TKey): MockState[TKey] { + if (isEntitySliceKey(key)) { + return this.entitySnapshot(key) as MockState[TKey] + } + + return this.meta[key as keyof MetaState] as MockState[TKey] + } + + async setSlice(key: TKey, value: MockState[TKey]) { + if (isEntitySliceKey(key)) { + await this.replaceEntitySlice(key, value as unknown as Array>) + return + } + + this.meta = { + ...this.meta, + [key]: clone(value), + } + } + + findEntities( + key: TKey, + predicate: (record: EntityRecord) => boolean = () => true, + ): Array> { + return this.collection(key) + .all() + .filter((record) => predicate(this.toPlainRecord(record))) + .map((record) => this.toPlainRecord(record)) + } + + findEntity(key: TKey, id: string) { + const record = this.findInternalEntity(key, id) + + return record ? this.toPlainRecord(record) : undefined + } + + async createEntity( + key: TKey, + record: EntityRecord, + options: { prepend?: boolean } = {}, + ): Promise> { + const collection = this.collection(key) + const created = await collection.create(clone(record)) + + if (options.prepend) { + const records = collection.all() + const appended = records.pop() + + if (appended) { + records.unshift(appended) + } + } + + return this.toPlainRecord(created) + } + + async updateEntity( + key: TKey, + id: string, + updater: (record: EntityRecord) => EntityRecord, + ) { + const collection = this.collection(key) + const current = this.findInternalEntity(key, id) + + if (!current) { + return undefined + } + + const next = updater(this.toPlainRecord(current)) + const updated = await collection.update(current, { + data: (draft) => replaceDraft(draft, next), + }) + + return updated ? this.toPlainRecord(updated) : undefined + } + + async deleteEntity(key: TKey, id: string) { + const current = this.findInternalEntity(key, id) + + if (!current) { + return undefined + } + + return this.toPlainRecord(this.collection(key).delete(current) ?? current) + } + + snapshot(): MockState { + return { +{{SNAPSHOT_PROPERTIES}} + } + } + + now() { + return this.meta.clock.now + } + + daysFromNow(days: number) { + return new Date(Date.parse(this.now()) + days * dayMs).toISOString() + } + + async transaction(callback: () => Promise | T): Promise { + const result = await callback() + await this.persist() + + return result + } + + private collection(key: TKey) { + return this.collections[key] as unknown as EntityCollection> + } + + private findInternalEntity(key: TKey, id: string) { + const idField = entityIdFields[key] as keyof EntityRecord + + return this.collection(key).findFirst((query) => + query.where((record: EntityRecord) => String(record[idField]) === id), + ) + } + + private entitySnapshot(key: TKey): MockState[TKey] { + return this.collection(key) + .all() + .map((record) => this.toPlainRecord(record)) as MockState[TKey] + } + + private async replaceState(state: MockState) { + this.meta = metaFromState(state) + + for (const key of entitySliceKeys) { + await this.replaceEntitySlice(key, state[key] as unknown as Array>) + } + } + + private async replaceEntitySlice( + key: TKey, + records: Array>, + ) { + const collection = this.collection(key) + + collection.clear() + for (const record of records) { + await collection.create(clone(record) as EntityRecord) + } + } + + private toPlainRecord(record: TRecord): TRecord { + return clone({ ...record }) as TRecord + } + + private async persist() { + await this.persistState?.(this.snapshot()) + } +} + +export const newMswDataStateStore = async ( + options: MswDataStateStoreOptions = {}, +): Promise => { + const store = new MswDataStateStore({ persist: options.persist }) + + await store.replace(options.initialState ?? seedState()) + + return store +} + +export const newMemoryMockStateStore = (initialState: MockState = seedState()) => + newMswDataStateStore({ initialState }) diff --git a/tests/support.py b/tests/support.py index 6ebaa9c..2af1544 100644 --- a/tests/support.py +++ b/tests/support.py @@ -141,14 +141,26 @@ def default_behavior() -> str: def seed_runtime_templates(fs: FileSystem) -> None: templates = { - "app.ts.tpl": "{{STARTER_HEADER}}\nconst basePath = {{DEFAULT_BASE_PATH}}\n{{RUNTIME_IMPORTS}}\n{{ROUTE_REGISTRATIONS}}\n", + "app.ts.tpl": ( + "{{STARTER_HEADER}}\n" + "const basePath = {{DEFAULT_BASE_PATH}}\n" + "const mockControllers = controllers ?? await newMemoryMockApiControllers()\n" + "{{RUNTIME_IMPORTS}}\n" + "{{ROUTE_REGISTRATIONS}}\n" + ), "codegen-mock-runtime.ts.tpl": "{{GENERATED_HEADER}}\nexport const runtimeConfigs = [\n{{RUNTIME_CONFIGS}}\n]\n", "controllers.ts.tpl": ( "{{STARTER_HEADER}}\n{{PRODUCT_IMPORTS}}\n{{OPERATION_IMPORTS}}\n" - "export type MockApiDependencies = { state: unknown }\n" + "import { newMockApiDependencies, type MockApiDependencies } from './dependencies.ts'\n" + "import { newFileMockStateStore, type MockStateOptions } from './lib/nodeStateStore.ts'\n" + "export type { MockApiDependencies }\n" "const operationCount = {{OPERATION_COUNT}}\n" - "export const newMockApiControllers = (deps: MockApiDependencies): {{PRODUCT_TYPES}} => ({\n" + "export const newMockApiControllers = async (_options: MockStateOptions = {}): Promise<{{PRODUCT_TYPES}}> => {\n" + "const stateStore = await newFileMockStateStore(_options)\n" + "const deps = newMockApiDependencies(stateStore)\n" + "return {\n" "{{CONTROLLER_SPREADS}}\n})\n" + "}\n" ), "openapi-ts.config.ts.tpl": "{{GENERATED_HEADER}}\nexport default [\n{{CONFIGS}}\n]\n", "operation-controller.ts.tpl": ( @@ -180,6 +192,18 @@ def seed_runtime_templates(fs: FileSystem) -> None: " }\n" "}\n" ), + "state-store.ts.tpl": ( + "{{STARTER_HEADER}}" + "import { Collection } from '@msw/data'\n" + "{{ZOD_IMPORT}}" + "export type EntitySliceKey = {{ENTITY_UNION}}\n" + "type EntityRecordMap = {\n{{ENTITY_RECORD_MAP}}\n}\n" + "const entitySliceKeys = [\n{{ENTITY_KEYS}}\n]\n" + "const entityIdFields = {\n{{ENTITY_ID_FIELDS}}\n}\n" + "const newEntityCollections = () => ({\n{{ENTITY_COLLECTIONS}}\n})\n" + "{{META_PROPERTIES}}\n" + "{{SNAPSHOT_PROPERTIES}}\n" + ), } for name, content in templates.items(): fs.write_text(TEMPLATE_ROOT / name, content) @@ -196,12 +220,19 @@ def seed_skill_template(fs: FileSystem) -> None: json.dumps( { "name": "__MOCKAPI_PACKAGE_NAME__", + "exports": { + "./browser": "./src/browser.ts", + "./package.json": "./package.json", + }, "scripts": { "build": "node scripts/build.mjs", "codegen": "tsx scripts/codegen-admin-openapi.ts && openapi-ts && tsx scripts/codegen-mock-runtime.ts", "codegen:contract": "tsx scripts/codegen-admin-openapi.ts && openapi-ts", "test": "vitest run", }, + "dependencies": { + "@msw/data": "1.1.6", + }, "devDependencies": { "esbuild": "^0.28.0", "vitest": "^4.1.4", @@ -263,8 +294,11 @@ def seed_skill_template(fs: FileSystem) -> None: ) for path, body in { "src/generated/mock-admin/state/controller.ts": "admin controller\n", - "src/generated/mock-admin/state/repository.ts": "admin repository\n", "src/generated/mock-admin/state/service.ts": "admin service\n", + "src/dependencies.ts": "dependencies\n", + "src/lib/nodeStateStore.ts": "node state store\n", + "src/lib/browserStateStore.ts": "browser state store\n", + "src/browser.ts": "browser entry\n", }.items(): fs.write_text( SKILL_ROOT / f"assets/templates/mock-server/{path}", diff --git a/tests/test_files.py b/tests/test_files.py index 7a41cd7..b1724e4 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -46,8 +46,8 @@ def test_copy_template_writes_overwrites_generated_scripts(self) -> None: self.fs.write_text(template_root / "scripts/codegen-admin-openapi.ts", "admin script\n") self.fs.write_text(template_root / "scripts/lib/mockRuntimeCodegen.ts", "runtime script\n") self.fs.write_text(template_root / "src/generated/mock-admin/state/controller.ts", "admin controller\n") - self.fs.write_text(template_root / "src/generated/mock-admin/state/repository.ts", "admin repository\n") self.fs.write_text(template_root / "src/generated/mock-admin/state/service.ts", "admin service\n") + self.fs.write_text(template_root / "src/lib/stateStore.ts", "state store\n") writes = self.writer.copy_template_writes(template_root, out_root) by_path = {write.path: write for write in writes} @@ -55,8 +55,8 @@ def test_copy_template_writes_overwrites_generated_scripts(self) -> None: self.assertTrue(by_path[out_root / "scripts/codegen-admin-openapi.ts"].overwrite) self.assertTrue(by_path[out_root / "scripts/lib/mockRuntimeCodegen.ts"].overwrite) self.assertTrue(by_path[out_root / "src/generated/mock-admin/state/controller.ts"].overwrite) - self.assertTrue(by_path[out_root / "src/generated/mock-admin/state/repository.ts"].overwrite) self.assertTrue(by_path[out_root / "src/generated/mock-admin/state/service.ts"].overwrite) + self.assertFalse(by_path[out_root / "src/lib/stateStore.ts"].overwrite) class LocalFileSystemTests(unittest.TestCase): diff --git a/tests/test_generate.py b/tests/test_generate.py index cec5cff..d3a491c 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -153,6 +153,11 @@ def test_dry_run_returns_planned_files(self) -> None: self.assertTrue(result.ok) self.assertTrue(any(file.path.endswith("src/app.ts") for file in result.files)) self.assertTrue(any(file.path.endswith("src/controllers.ts") for file in result.files)) + self.assertTrue(any(file.path.endswith("src/dependencies.ts") for file in result.files)) + self.assertTrue(any(file.path.endswith("src/lib/stateStore.ts") for file in result.files)) + self.assertTrue(any(file.path.endswith("src/lib/nodeStateStore.ts") for file in result.files)) + self.assertTrue(any(file.path.endswith("src/lib/browserStateStore.ts") for file in result.files)) + self.assertTrue(any(file.path.endswith("src/browser.ts") for file in result.files)) self.assertTrue(any(file.path.endswith("scripts/codegen-admin-openapi.ts") for file in result.files)) self.assertTrue(any(file.path.endswith("openapi/admin.source.yaml") for file in result.files)) self.assertFalse(any(file.path.endswith("openapi/admin.yaml") for file in result.files)) @@ -197,7 +202,8 @@ def test_initial_controllers_scaffold_wires_operation_adapters(self) -> None: self.assertIn("./features/workspaces/controllers/getWorkspace.ts", controllers) self.assertIn("...newListWorkspacesController(deps),", controllers) self.assertIn("...newGetWorkspaceController(deps),", controllers) - self.assertIn("export type MockApiDependencies", controllers) + self.assertIn("export type { MockApiDependencies }", controllers) + self.assertIn("newMockApiDependencies(stateStore)", controllers) self.assertNotIn("new WorkspacesService", controllers) self.assertNotIn("./features/workspaces/service.ts", controllers) self.assertNotIn("./features/workspaces/repository.ts", controllers) @@ -247,12 +253,12 @@ def test_existing_admin_state_files_are_rewritten(self) -> None: self.assertTrue(result.ok) self.assertEqual(self.fs.read_text(controller_path), f"{GENERATED_SCRIPT_HEADER}admin controller\n") - self.assertEqual(self.fs.read_text(repository_path), f"{GENERATED_SCRIPT_HEADER}admin repository\n") + self.assertEqual(self.fs.read_text(repository_path), "// custom admin repository\n") self.assertEqual(self.fs.read_text(service_path), f"{GENERATED_SCRIPT_HEADER}admin service\n") self.assertTrue(self.fs.read_text(seed_path).startswith(GENERATED_SCRIPT_HEADER)) actions_by_path = {file.path: file.action for file in result.files} self.assertEqual(actions_by_path[str(controller_path)], "updated") - self.assertEqual(actions_by_path[str(repository_path)], "updated") + self.assertNotIn(str(repository_path), actions_by_path) self.assertEqual(actions_by_path[str(service_path)], "updated") self.assertEqual(actions_by_path[str(seed_path)], "updated") self.assertFalse(any(path.endswith("/src/generated/mock-admin/state/types.ts") for path in actions_by_path)) diff --git a/tests/test_quality.py b/tests/test_quality.py index eaaf62b..9067f01 100644 --- a/tests/test_quality.py +++ b/tests/test_quality.py @@ -195,6 +195,17 @@ def test_errors_for_direct_product_slice_access_in_feature_behavior(self) -> Non self.assertFalse(result.ok) self.assertIn("quality.stateAccess.directSliceAccess", diagnostic_ids(result.errors)) + def test_errors_for_direct_entity_store_access_in_feature_behavior(self) -> None: + self.add_package_file( + "src/features/workspaces/service.ts", + "const workspaces = stateStore.findEntities('workspaces')\nawait stateStore.createEntity('workspaces', workspace)\n", + ) + + result = self.check() + + self.assertFalse(result.ok) + self.assertIn("quality.stateAccess.directSliceAccess", diagnostic_ids(result.errors)) + def test_accepts_direct_id_counter_slice_access_in_feature_behavior(self) -> None: self.fs.write_text(PACKAGE_ROOT / "openapi/admin.yaml", "idCounters: {}\n") self.add_package_file( @@ -220,20 +231,20 @@ def test_accepts_clear_style_service_and_repository(self) -> None: self.add_package_file( "src/features/workspaces/repository.ts", """export class WorkspaceRepository { - constructor(private readonly stateStore: MockStateRepository) {} - visible() { return this.stateStore.getSlice('workspaces') } - create(workspace: WorkspaceRecord) { this.stateStore.setSlice('workspaces', [workspace, ...this.visible()]) } + constructor(private readonly stateStore: MockStateStore) {} + visible() { return this.stateStore.findEntities('workspaces') } + create(workspace: WorkspaceRecord) { return this.stateStore.createEntity('workspaces', workspace, { prepend: true }) } } """, ) self.add_package_file( "src/features/workspaces/service.ts", """export class WorkspaceService { - constructor(private readonly stateStore: MockStateRepository, private readonly workspaces: WorkspaceRepository) {} + constructor(private readonly stateStore: MockStateStore, private readonly workspaces: WorkspaceRepository) {} create(draft: WorkspaceDraft) { - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) - this.workspaces.create({ ...draft, id: ids.next('workspace'), updatedAt: this.stateStore.now() }) + await this.workspaces.create({ ...draft, id: ids.next('workspace'), updatedAt: this.stateStore.now() }) }) } } diff --git a/tests/test_render_services.py b/tests/test_render_services.py index ee7b685..6c120e4 100644 --- a/tests/test_render_services.py +++ b/tests/test_render_services.py @@ -74,6 +74,7 @@ def test_project_renderer_plans_generated_and_scaffold_files(self) -> None: self.assertTrue(by_path["openapi-ts.config.ts"].overwrite) self.assertTrue(by_path["openapi/admin.source.yaml"].overwrite) self.assertFalse(by_path["src/app.ts"].overwrite) + self.assertFalse(by_path["src/lib/stateStore.ts"].overwrite) self.assertTrue(by_path["src/generated/mock-admin/state/seed.ts"].overwrite) self.assertNotIn("openapi/admin.yaml", by_path) self.assertNotIn("src/generated/mock-admin/state/types.ts", by_path) @@ -90,6 +91,23 @@ def test_project_renderer_plans_generated_and_scaffold_files(self) -> None: self.assertIn('const basePath = "/api"', by_path["src/app.ts"].content) self.assertIn("basePath: basePath,", by_path["src/app.ts"].content) + def test_project_renderer_renders_msw_data_state_store_for_entity_slices(self) -> None: + profile = profile_model(profile_toml()) + context = create_generate_context(profile, PROJECT_ROOT) + + writes = ProjectRenderService(self.fs, self.template_service, ADMIN_OPENAPI_TEMPLATE_PATH).planned_writes(context) + state_store = { + write.path.relative_to(context.outRoot).as_posix(): write.content + for write in writes + }["src/lib/stateStore.ts"] + + self.assertIn("import { Collection } from '@msw/data'", state_store) + self.assertIn("zWorkspaceRecord", state_store) + self.assertIn('export type EntitySliceKey = "workspaces"', state_store) + self.assertIn('workspaces: MockState["workspaces"][number]', state_store) + self.assertIn('workspaces: "id",', state_store) + self.assertIn("workspaces: new Collection({ schema: zWorkspaceRecord })", state_store) + def test_project_renderer_uses_current_time_for_seed_now_default(self) -> None: profile = profile_model(profile_toml()) context = create_generate_context(profile, PROJECT_ROOT) diff --git a/tests/test_template_contents.py b/tests/test_template_contents.py index 512b530..bf5c6a6 100644 --- a/tests/test_template_contents.py +++ b/tests/test_template_contents.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import unittest from pathlib import Path @@ -23,8 +24,23 @@ def test_config_defaults_to_package_local_runtime_db(self) -> None: def test_server_logs_state_snapshot_path(self) -> None: server = read_template("src/server.ts") + self.assertIn("const app = await newMockApiApp", server) + self.assertIn("controllers: await newMockApiControllers", server) self.assertIn("State snapshot: ${config.stateFile}", server) + def test_package_exports_browser_state_store_entrypoint(self) -> None: + package = json.loads(read_template("package.json")) + + self.assertEqual(package["dependencies"]["@msw/data"], "1.1.6") + self.assertEqual(package["exports"]["./browser"], "./src/browser.ts") + + def test_browser_entrypoint_exports_browser_store(self) -> None: + browser = read_template("src/browser.ts") + + self.assertIn("newBrowserMockStateStore", browser) + self.assertIn("newMockApiDependencies", browser) + self.assertIn("zMockState", browser) + def test_template_ignores_runtime_state_directory(self) -> None: gitignore = read_template(".gitignore")