@fougere/nuxt 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fougere contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @fougere/nuxt
2
+ > The client primitives and the server surface
3
+ `useQuery` / `useCommand` (the pair — a command on X revalidates the mounted queries on
4
+ X), `useFormFor` (a contract, not a rendering), `useCurrentUser`, and `invoke` on the
5
+ server side. The metadata IS the imported entity class — nothing is serialized to the
6
+ browser.
7
+
8
+ ## Installation
9
+ ```bash
10
+ pnpm add @fougere/nuxt
11
+ ```
12
+
13
+ ---
14
+
15
+ Part of [Fougere](https://github.com/chok/fougere) — one schema, a gradient from
16
+ monolith to distributed, the same user code.
17
+ Reference documentation: [the site](https://chok.github.io/fougere/) (en/fr).
@@ -0,0 +1,16 @@
1
+ import type { SeedEntry, FougereConfig } from '@fougere/core';
2
+ export interface FougereModuleOptions {
3
+ /** Override fougere.config.ts values from nuxt.config. Optional. */
4
+ db?: FougereConfig['db'];
5
+ frondsDir?: string;
6
+ /**
7
+ * Where `fronds/` lives, relative to the app's rootDir. Default: the app
8
+ * itself. Set to `../..` for an app under `apps/*` in a workspace whose
9
+ * fronds are shared at the root. Config and `.fougere` stay app-local.
10
+ */
11
+ root?: string;
12
+ }
13
+ declare const module: import("@nuxt/schema").NuxtModule<FougereModuleOptions, FougereModuleOptions, false>;
14
+ export default module;
15
+ export declare function generateBootPlugin(config: FougereConfig, seeds: SeedEntry[], fougereAppPath: string): string;
16
+ //# sourceMappingURL=module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAK9D,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,EAAE,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAGD,QAAA,MAAM,MAAM,sFA4JV,CAAC;eAEY,MAAM;AAMrB,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,SAAS,EAAE,EAClB,cAAc,EAAE,MAAM,GACrB,MAAM,CA+DR"}
package/dist/module.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @fougere/nuxt — Nuxt module: scans fronds, registers the four
3
+ * primitives (useQuery/useCommand, useFormFor, useCurrentUser) and the
4
+ * server surface (call envelope, session, REST bridge, auth).
5
+ */
6
+ import { defineNuxtModule, addServerHandler, addServerImportsDir, addServerPlugin, addPlugin, addTemplate, addImports, createResolver, } from '@nuxt/kit';
7
+ import { scanProject, frondAliases, FROND_DIRS, setModuleLoader, loadCascadedConfig, orderSeeds } from '@fougere/core';
8
+ import { declaresStorage } from '@fougere/runtime';
9
+ import { createJiti } from 'jiti';
10
+ import { resolve } from 'node:path';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ const module = defineNuxtModule({
13
+ meta: {
14
+ name: '@fougere/nuxt',
15
+ configKey: 'fougere',
16
+ },
17
+ defaults: {
18
+ frondsDir: 'fronds',
19
+ },
20
+ async setup(options, nuxt) {
21
+ const { resolve: resolveModule } = createResolver(import.meta.url);
22
+ const runtimeResolve = (...path) => resolveModule('../src/runtime', ...path);
23
+ const rootDir = nuxt.options.rootDir;
24
+ // Fronds may live at the workspace root (app under apps/*); config/.fougere stay app-local.
25
+ const scanRoot = options.root ? resolve(rootDir, options.root) : rootDir;
26
+ // The runtime app (fougereApp) scans fronds too; hand it the same root via
27
+ // env (the Nitro dev worker inherits the parent env, like FORCE_COLOR above).
28
+ process.env.FOUGERE_ROOT = scanRoot;
29
+ // Propagate color support to Nitro dev worker (inherits parent env but has no TTY)
30
+ if (process.stdout?.isTTY && !process.env.NO_COLOR) {
31
+ process.env.FORCE_COLOR ??= '1';
32
+ }
33
+ // Prevent Nitro from bundling the TypeScript compiler (~9 MB).
34
+ // @fougere/core lazy-imports it, but Rollup still code-splits dynamic imports
35
+ // into the bundle — only external truly excludes it.
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ nuxt.hook('nitro:config', (nitroConfig) => {
38
+ nitroConfig.rollupConfig ??= {};
39
+ nitroConfig.rollupConfig.external ??= [];
40
+ if (Array.isArray(nitroConfig.rollupConfig.external)) {
41
+ nitroConfig.rollupConfig.external.push('typescript');
42
+ }
43
+ // invoke reads the current request (state) through nitro's async context
44
+ nitroConfig.experimental = { ...nitroConfig.experimental, asyncContext: true };
45
+ });
46
+ // ── 0. Setup TS-aware module loader before reading any user config ──
47
+ // The Vite alias below covers `.vue` pages; this covers the SCAN, which loads a
48
+ // frond's own sources — so `@frond/user/entities/User.js` inside a handler resolves
49
+ // for the same reason it does in a page.
50
+ const jiti = createJiti(import.meta.url, { interopDefault: true, alias: await frondAliases(scanRoot) });
51
+ setModuleLoader((filePath) => jiti.import(filePath));
52
+ // ── 0b. Load fougere.config.ts along the workspace→app cascade (scanRoot is
53
+ // the workspace when the app declares `root`); module options override. ──
54
+ const fileConfig = await loadCascadedConfig(scanRoot, rootDir);
55
+ const optionsOverride = Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined));
56
+ const config = { db: 'sqlite', ...fileConfig, ...optionsOverride };
57
+ // ── 1. Scan fronds (filtered by FOUGERE_FRONDS env var) ──
58
+ const frondsFilter = process.env.FOUGERE_FRONDS?.split(',').map((s) => s.trim()).filter(Boolean);
59
+ const { fronds } = await scanProject(scanRoot, frondsFilter);
60
+ // ── 1b. Register @frond/* aliases for all fronds, and watch them ──
61
+ // The scanned fronds ARE the watch list — nothing to declare. Without this, a
62
+ // frond under `apps/../..` sits outside rootDir, so Nuxt never restarts: the scan,
63
+ // the additive migration (once per boot) and the seeds all keep the previous shape,
64
+ // and a field you just added is simply absent with no error anywhere.
65
+ // The root frond IS the scan root, so watching its path would match every write in
66
+ // the project — `.nuxt/`, `node_modules/`, the build output. Its convention
67
+ // directories are the frond, and they are what changes when the domain changes.
68
+ for (const frond of fronds) {
69
+ nuxt.options.alias[`@frond/${frond.name}`] = frond.source.path;
70
+ const watched = frond.source.path === scanRoot
71
+ ? FROND_DIRS.map((dir) => resolve(scanRoot, dir))
72
+ : [frond.source.path];
73
+ nuxt.options.watch.push(...watched);
74
+ }
75
+ // ── 1c. Register aliases for synced remote fronds (.fougere/remotes.json) ──
76
+ const remotesPath = resolve(rootDir, '.fougere', 'remotes.json');
77
+ if (existsSync(remotesPath)) {
78
+ try {
79
+ const remotes = JSON.parse(readFileSync(remotesPath, 'utf-8'));
80
+ for (const [name, meta] of Object.entries(remotes)) {
81
+ // Don't override locally scanned fronds
82
+ if (!nuxt.options.alias[`@frond/${name}`]) {
83
+ nuxt.options.alias[`@frond/${name}`] = meta.path;
84
+ // Ensure Vite/Nitro can resolve files inside synced remotes
85
+ nuxt.options.build.transpile.push(meta.path);
86
+ }
87
+ }
88
+ }
89
+ catch { /* corrupt remotes.json — skip */ }
90
+ }
91
+ // ── 2. Composables — the primitives, nothing else ──
92
+ addImports([
93
+ { name: 'useQuery', from: runtimeResolve('composables/useFougereData') },
94
+ { name: 'useCommand', from: runtimeResolve('composables/useFougereData') },
95
+ { name: 'useFormFor', from: runtimeResolve('composables/useFormFor') },
96
+ { name: 'useCurrentUser', from: runtimeResolve('composables/useCurrentUser') },
97
+ ]);
98
+ // ── 3. Server: call envelope endpoint + catch-all route + shared utils ────
99
+ addServerHandler({
100
+ route: '/_fougere/call',
101
+ method: 'post',
102
+ handler: runtimeResolve('server/routes/call.post'),
103
+ });
104
+ // The same door, per audience: `/_fougere/call/public` serves the surface named
105
+ // `public`, the way `generateRoutes(app, { surface })` does for REST. The handler
106
+ // reads the segment (see `surfaceOf`).
107
+ addServerHandler({
108
+ route: '/_fougere/call/**',
109
+ method: 'post',
110
+ handler: runtimeResolve('server/routes/call.post'),
111
+ });
112
+ addServerHandler({
113
+ route: '/_fougere/session',
114
+ method: 'get',
115
+ handler: runtimeResolve('server/routes/session.get'),
116
+ });
117
+ // Session hydration — the page ships with its user, no round-trip
118
+ addPlugin({ src: runtimeResolve('plugins/session.server'), mode: 'server' });
119
+ addServerHandler({
120
+ route: '/api/**',
121
+ handler: runtimeResolve('server/api/crud'),
122
+ });
123
+ addServerImportsDir(runtimeResolve('server/utils'));
124
+ // ── 5b. Auth (mounted when fougere.config.ts declares `auth`) ──
125
+ if (config.auth) {
126
+ // Session middleware — resolves user on every request
127
+ addServerHandler({
128
+ middleware: true,
129
+ handler: runtimeResolve('server/auth/middleware/auth'),
130
+ });
131
+ // Auth catch-all route (login, register, callback, etc.)
132
+ addServerHandler({
133
+ route: '/auth/**',
134
+ handler: runtimeResolve('server/auth/routes/auth/[...]'),
135
+ });
136
+ // /api/me — current user endpoint
137
+ addServerHandler({
138
+ route: '/api/me',
139
+ method: 'get',
140
+ handler: runtimeResolve('server/auth/routes/api/me.get'),
141
+ });
142
+ }
143
+ // ── 6. Boot plugin (virtual — lives in .nuxt/) ───
144
+ const allSeeds = orderSeeds(fronds);
145
+ const bootTpl = addTemplate({
146
+ filename: 'fougere-boot.ts',
147
+ write: true,
148
+ getContents: () => generateBootPlugin(config, allSeeds, runtimeResolve('server/utils/fougereApp')),
149
+ });
150
+ addServerPlugin(bootTpl.dst);
151
+ },
152
+ });
153
+ export default module;
154
+ // ── Boot plugin generation ─────────────────────────
155
+ // Exported (not just module-internal) so its output is unit-testable without
156
+ // spinning up a whole Nuxt build.
157
+ export function generateBootPlugin(config, seeds, fougereAppPath) {
158
+ const lines = [];
159
+ lines.push(`// Auto-generated by @fougere/nuxt — do not edit`);
160
+ // Explicit imports — nitro's auto-imports don't reach this template in a prod build
161
+ lines.push(`import { defineNitroPlugin } from 'nitropack/runtime';`);
162
+ lines.push(`import { configureFougere } from '${fougereAppPath}';`);
163
+ if (seeds.length)
164
+ lines.push(`import { runSeeds } from '@fougere/core';`);
165
+ const db = config.db ?? 'sqlite';
166
+ // `declaresStorage` is the canonical reader of `db:` — asked, not re-interpreted.
167
+ // Reading `dialect` here made this codegen a SECOND reader, and the two disagreed:
168
+ // any value but 'sqlite' emitted an empty plugin, so no config, no seeds, not a word.
169
+ // resolveStorage now refuses an unresolvable dialect by name, at boot, out loud.
170
+ if (!declaresStorage(db)) {
171
+ lines.push(`export default defineNitroPlugin(() => {});`);
172
+ return lines.join('\n') + '\n';
173
+ }
174
+ // The generated plugin names no storage package — resolution lives in
175
+ // @fougere/runtime, the one place that knows which engine backs `db:`.
176
+ lines.push(`import { resolveStorage } from '@fougere/runtime';`);
177
+ lines.push(``);
178
+ // Seed imports
179
+ for (let i = 0; i < seeds.length; i++) {
180
+ lines.push(`import seed_${i} from '${seeds[i].filePath}';`);
181
+ }
182
+ if (seeds.length)
183
+ lines.push(``);
184
+ // Wrap all init in the plugin callback to avoid top-level native calls
185
+ lines.push(`export default defineNitroPlugin(async () => {`);
186
+ // Pass `db` through unchanged — resolveStorage (@fougere/runtime → setupSqlite)
187
+ // is the one place that defaults an absent path, so both call sites (this
188
+ // codegen'd plugin and fougereApp.ts's own fallback) land on the same file.
189
+ lines.push(` const storage = resolveStorage(${JSON.stringify(db)});`);
190
+ lines.push(``);
191
+ lines.push(` configureFougere({`);
192
+ lines.push(` db: storage.db,`);
193
+ lines.push(` ormFactory: storage.ormFactory,`);
194
+ lines.push(` async afterBoot(app) {`);
195
+ lines.push(` await storage.afterBoot?.(app);`);
196
+ if (seeds.length) {
197
+ // The seeding LOOP is core's (`runSeeds`), not written out here: a second copy
198
+ // drifted, and the one that had lost its storage fallback was this one — the one
199
+ // that actually runs when you open the app. Codegen's only job is the static
200
+ // imports, which is the one thing a bundler needs spelled out.
201
+ //
202
+ // `report` is passed: its default is a no-op, so the boot you actually open said
203
+ // nothing about a skipped seed — the very silence F-12 was aggravated by.
204
+ lines.push(` await runSeeds(app, [`);
205
+ for (let i = 0; i < seeds.length; i++) {
206
+ lines.push(` { entityName: '${seeds[i].entityName}', data: seed_${i}, filePath: ${JSON.stringify(seeds[i].filePath)} },`);
207
+ }
208
+ lines.push(` ], (message) => console.log('[fougere:seed]' + message));`);
209
+ }
210
+ lines.push(` },`);
211
+ lines.push(` });`);
212
+ lines.push(`});`);
213
+ return lines.join('\n') + '\n';
214
+ }
215
+ //# sourceMappingURL=module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.js","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@fougere/nuxt",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "The Nuxt module: Fougere's client primitives and server surface.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chok/fougere.git",
9
+ "directory": "packages/app/nuxt"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/module.js",
13
+ "types": "dist/module.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/module.d.ts",
17
+ "import": "./dist/module.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src/runtime"
23
+ ],
24
+ "dependencies": {
25
+ "@nuxt/kit": "^4.5.1",
26
+ "jiti": "^2.6.1",
27
+ "@fougere/container-fougere": "0.1.0-alpha.0",
28
+ "@fougere/runtime": "0.1.0-alpha.0",
29
+ "@fougere/schema": "0.1.0-alpha.0",
30
+ "@fougere/core": "0.1.0-alpha.0",
31
+ "@fougere/transport-http": "0.1.0-alpha.0"
32
+ },
33
+ "devDependencies": {
34
+ "@nuxt/schema": "^4.5.1",
35
+ "nitropack": "^2.13.2",
36
+ "nuxt": "^4.5.1",
37
+ "vue": "^3.5.40",
38
+ "vitest": "^4.1.0"
39
+ },
40
+ "peerDependencies": {
41
+ "nuxt": "^4.0.0",
42
+ "vue": "^3.5.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "rm -rf dist && tsc",
49
+ "test": "vitest run --passWithNoTests",
50
+ "typecheck": "tsc --noEmit"
51
+ }
52
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The session: resolved once server-side, hydrated with the page, read
3
+ * here as reactive state. refresh() re-reads it after a client-side
4
+ * auth change (login, logout) — no page reload, no hand-rolled /api/me.
5
+ *
6
+ * `session` is the whole view (extensible context); `user` is its
7
+ * everyday projection.
8
+ */
9
+ import { useState, useRequestFetch } from '#imports';
10
+ import { computed } from 'vue';
11
+ import type { SessionView } from '../session/view.js';
12
+
13
+ export function useCurrentUser<TUser = Record<string, unknown>>() {
14
+ const session = useState<SessionView>('fougere:session', () => ({ user: null }));
15
+ const fetcher = useRequestFetch();
16
+
17
+ const user = computed(() => (session.value.user ?? null) as TUser | null);
18
+ const loggedIn = computed(() => session.value.user != null);
19
+
20
+ async function refresh(): Promise<void> {
21
+ session.value = await fetcher<SessionView>('/_fougere/session');
22
+ }
23
+
24
+ return { session, user, loggedIn, refresh };
25
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The form contract — state, validation, submission, error mapping.
3
+ * Never a widget: the page owns the rendering, this owns the mechanics.
4
+ *
5
+ * The cycle composes the other primitives: fields from the io axes,
6
+ * local pre-judgment with the same rules the handler enforces (one
7
+ * declaration, both sides), submission through the command (so the
8
+ * entity link revalidates mounted queries), and per-field errors in
9
+ * the same `{ path, message }` shape whoever judged.
10
+ */
11
+ import { reactive, computed } from 'vue';
12
+ import { FougereError, ErrorCode, toRegistrationName } from '@fougere/core/contract';
13
+ import { useCommand } from './useFougereData.js';
14
+ import { formFieldsOf, payloadOf, errorsByField, type FormEntity, type FormField } from '../form/fields.js';
15
+
16
+ export interface FormOptions {
17
+ /** Command the submit rides. Default: 'create'. */
18
+ op?: string;
19
+ /** Initial values (edit mode: the loaded entity). */
20
+ initial?: Record<string, unknown>;
21
+ /** Call params designating the target (edit mode: { id }). */
22
+ params?: Record<string, string>;
23
+ }
24
+
25
+ export function useFormFor<T = Record<string, unknown>>(entity: FormEntity, options: FormOptions = {}) {
26
+ const entityKey = toRegistrationName(entity.name);
27
+ const fields: FormField[] = formFieldsOf(entity, entityKey);
28
+
29
+ // `initial` wins over the declared default: editing a row shows the row, including a
30
+ // value the author deliberately changed away from that default. On a create form
31
+ // there is no `initial`, so the field opens on what is about to be written — the
32
+ // schema's own literal, shown rather than guessed by the page.
33
+ const values = reactive<Record<string, unknown>>(
34
+ Object.fromEntries(fields.map((f) => [f.name, options.initial?.[f.name] ?? f.default])),
35
+ );
36
+ const errors = reactive<Record<string, string>>({});
37
+ const command = useCommand<T>(entity, options.op ?? 'create');
38
+
39
+ function clearErrors() {
40
+ for (const key of Object.keys(errors)) delete errors[key];
41
+ }
42
+
43
+ /** Local pre-judgment — same rules as the handler, saves a lost round-trip. */
44
+ function judge(): boolean {
45
+ clearErrors();
46
+ const result = entity.validate(payloadOf(values));
47
+ if (result.success) return true;
48
+ Object.assign(errors, errorsByField(result.errors));
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Judge locally, then send through the command. Returns the created/updated
54
+ * value, or null when a judge (either side) rejected — the errors land per
55
+ * field either way, the form never knows who judged.
56
+ */
57
+ async function submit(): Promise<T | null> {
58
+ if (!judge()) return null;
59
+ try {
60
+ return await command.execute({ params: options.params, body: payloadOf(values) });
61
+ } catch (err) {
62
+ if (err instanceof FougereError && err.code === ErrorCode.VALIDATION_FAILED && Array.isArray(err.details)) {
63
+ Object.assign(errors, errorsByField(err.details as { path: string; message: string }[]));
64
+ return null;
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+
70
+ return {
71
+ fields,
72
+ /**
73
+ * The same fields, keyed by name — a form that lays its inputs out by hand binds
74
+ * one at a time (`v-bind="fieldsByName.email.attrs"`), and still states no rule of
75
+ * its own. Without it, a page retypes `type="email"` next to a card that says
76
+ * `format: 'email'`, and the browser enforces the page rather than the declaration.
77
+ */
78
+ fieldsByName: Object.fromEntries(fields.map((f) => [f.name, f])) as Record<string, FormField>,
79
+ values,
80
+ errors,
81
+ submit,
82
+ loading: command.loading,
83
+ /** Non-validation failure of the last submit (unreachable host, conflict…). */
84
+ error: command.error,
85
+ valid: computed(() => Object.keys(errors).length === 0),
86
+ };
87
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The couple — useQuery (reads) and useCommand (writes), the two dual
3
+ * gestures of a page talking to a Frond. Designation is class + verb:
4
+ * the imported entity class carries the metadata, its name carries the
5
+ * registration key.
6
+ *
7
+ * Both gestures ride the call envelope: the browser POSTs JSON-RPC to
8
+ * /_fougere/call; during SSR Nuxt collapses the same call to an
9
+ * in-process fetch (no network, no port).
10
+ *
11
+ * The link: a successful command on an entity revalidates every mounted
12
+ * query on that entity — designation gives the entity on both sides,
13
+ * nothing to declare.
14
+ */
15
+ import { useAsyncData, useRequestFetch, refreshNuxtData } from '#imports';
16
+ import { ref, computed, toValue, onScopeDispose, type MaybeRefOrGetter, type Ref } from 'vue';
17
+ import {
18
+ FougereError,
19
+ ErrorCode,
20
+ toRegistrationName,
21
+ type InvocationContext,
22
+ type FrondCall,
23
+ } from '@fougere/core/contract';
24
+ import { frameCall, unframeResponse, type RpcResponse } from '@fougere/transport-http/client';
25
+
26
+ /** An entity class is a designation: its name is the registration key. */
27
+ type EntityClass = { name: string };
28
+
29
+ /** What a page provides of an invocation — the rest is stamped server-side. */
30
+ export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | 'body'>>;
31
+
32
+ let nextId = 1;
33
+
34
+ /** Mounted queries per entity — the command side of the link reads this. */
35
+ const mounted = new Map<string, Set<string>>();
36
+
37
+ function invocationOf(input?: CallInput): InvocationContext {
38
+ return { params: {}, query: {}, body: undefined, state: {}, ...input };
39
+ }
40
+
41
+ type Fetcher = <T>(url: string, options: { method: 'POST'; body: unknown }) => Promise<T>;
42
+
43
+ async function send(fetcher: Fetcher, call: FrondCall, invocation: InvocationContext): Promise<unknown> {
44
+ const response = await fetcher<RpcResponse>('/_fougere/call', {
45
+ method: 'POST',
46
+ body: frameCall(call, invocation, nextId++),
47
+ });
48
+ return unframeResponse(response, call);
49
+ }
50
+
51
+ export async function useQuery<T = Record<string, unknown>>(
52
+ entity: EntityClass,
53
+ op: string,
54
+ input?: MaybeRefOrGetter<CallInput | undefined>,
55
+ opts?: { immediate?: boolean },
56
+ ) {
57
+ const entityKey = toRegistrationName(entity.name);
58
+ const call: FrondCall = { entity: entityKey, op };
59
+ const key = `fougere:${entityKey}.${op}:${JSON.stringify(toValue(input) ?? {})}`;
60
+ const fetcher = useRequestFetch() as Fetcher;
61
+
62
+ // Register before any await — the link and scope cleanup need the setup scope.
63
+ if (import.meta.client) {
64
+ const keys = mounted.get(entityKey) ?? new Set<string>();
65
+ keys.add(key);
66
+ mounted.set(entityKey, keys);
67
+ onScopeDispose(() => keys.delete(key));
68
+ }
69
+
70
+ const { data, pending, error, refresh } = await useAsyncData(
71
+ key,
72
+ () => send(fetcher, call, invocationOf(toValue(input))),
73
+ {
74
+ ...(input === undefined ? {} : { watch: [() => toValue(input)] }),
75
+ ...(opts?.immediate === false ? { immediate: false } : {}),
76
+ },
77
+ );
78
+
79
+ // A list result reads as items/total/hasMore whatever the wire delivered.
80
+ const items = computed<T[]>(() => {
81
+ const v = data.value as unknown;
82
+ if (Array.isArray(v)) return v as T[];
83
+ if (v && typeof v === 'object' && Array.isArray((v as { items?: unknown }).items)) {
84
+ return (v as { items: T[] }).items;
85
+ }
86
+ return [];
87
+ });
88
+ const total = computed(() => (data.value as { total?: number } | null)?.total);
89
+ const hasMore = computed(() => (data.value as { hasMore?: boolean } | null)?.hasMore);
90
+
91
+ return { data: data as Ref<T | null>, items, total, hasMore, loading: pending, error, refresh };
92
+ }
93
+
94
+ export function useCommand<T = unknown>(entity: EntityClass, op: string) {
95
+ const entityKey = toRegistrationName(entity.name);
96
+ const call: FrondCall = { entity: entityKey, op };
97
+ const fetcher = useRequestFetch() as Fetcher;
98
+ const loading = ref(false);
99
+ const error = ref<FougereError | null>(null);
100
+
101
+ async function execute(input?: CallInput): Promise<T> {
102
+ loading.value = true;
103
+ error.value = null;
104
+ try {
105
+ const result = (await send(fetcher, call, invocationOf(input))) as T;
106
+ // The link: same entity designated on both sides → revalidate its queries.
107
+ const keys = mounted.get(entityKey);
108
+ if (keys?.size) await refreshNuxtData([...keys]);
109
+ return result;
110
+ } catch (err) {
111
+ error.value =
112
+ err instanceof FougereError
113
+ ? err
114
+ : new FougereError({
115
+ code: ErrorCode.SERVICE_UNAVAILABLE,
116
+ message: (err as Error)?.message ?? String(err),
117
+ entity: entityKey,
118
+ operation: op,
119
+ cause: err,
120
+ });
121
+ throw error.value;
122
+ } finally {
123
+ loading.value = false;
124
+ }
125
+ }
126
+
127
+ return { execute, loading, error };
128
+ }