@stonecrop/nuxt 0.16.6 → 0.18.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/README.md CHANGED
@@ -125,7 +125,7 @@ export default defineNuxtPlugin(() => {
125
125
  })
126
126
  ```
127
127
 
128
- This wires `useStonecrop()`'s automatic record loading to your GraphQL (or any other) backend. Without this step, `useStonecrop({ doctype, recordId })` falls back to a REST fetch stub that may not exist in your app.
128
+ This wires record loading to your GraphQL (or any other) backend. It is the seam Desktop reads through as well. There is no fallback: without a registered client, `getRecord` and `getRecords` throw naming `setClient`, and Desktop skips the read and renders empty.
129
129
 
130
130
  ### Use the Stonecrop Composable
131
131
 
@@ -400,7 +400,7 @@ await dispatchAction({ name: 'plan' }, 'SUBMIT', [recordId])
400
400
  |-----------------|------|-------------|
401
401
  | `registry` | `Registry` | The Registry instance for doctype management. |
402
402
  | `stonecrop` | `Stonecrop` | The Stonecrop instance for HST and operation log access. Throws if not initialized. |
403
- | `setMeta(fn)` | `(fn: (ctx) => Doctype \| Promise<Doctype>) => void` | Sets the `getMeta` function on the Registry. Called by `useStonecrop()` to lazy-load doctype metadata for the current route. `ctx` = `{ path, segments }`. |
403
+ | `setMeta(fn)` | `(fn: (ctx) => Doctype \| Promise<Doctype>) => void` | Sets the `getMeta` function on the Registry. Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata. `ctx` = `{ path, segments }`. |
404
404
  | `setClient(client)` | `(client: DataClient) => void` | Set the data client for record fetching. Throws if stonecrop not available. |
405
405
  | `getClient()` | `() => DataClient \| undefined` | Get the currently configured client. |
406
406
  | `dispatchAction(doctype, action, args)` | `Promise<{ success, data, error }>` | Dispatch an action via the configured client. Returns error if doctype not found in registry. |
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stonecrop/nuxt",
3
3
  "configKey": "stonecrop",
4
- "version": "0.16.6",
4
+ "version": "0.18.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,4 +1,4 @@
1
- type Field = Record<string, unknown>;
1
+ import { type Field } from './docbuilderFields.js';
2
2
  type __VLS_Props = {
3
3
  modelValue: Field[];
4
4
  };
@@ -129,6 +129,7 @@
129
129
  import { ATable, ARow } from "@stonecrop/atable";
130
130
  import { CANONICAL_COMPONENTS, INTROSPECTED_IDENTITY_PROPS } from "@stonecrop/schema";
131
131
  import { computed, nextTick, ref, useId } from "vue";
132
+ import { updateFieldAt } from "./docbuilderFields";
132
133
  const IDENTITY_PROPS = new Set(INTROSPECTED_IDENTITY_PROPS);
133
134
  const isIdentity = (key) => IDENTITY_PROPS.has(key);
134
135
  const componentListId = useId();
@@ -230,17 +231,7 @@ function collapseAllRows() {
230
231
  });
231
232
  }
232
233
  function update(realIndex, key, val) {
233
- emit(
234
- "update:modelValue",
235
- props.modelValue.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f)
236
- );
237
- }
238
- function setOrDelete(field, key, val) {
239
- if (val === void 0) {
240
- const { [key]: _omit, ...rest } = field;
241
- return rest;
242
- }
243
- return { ...field, [key]: val };
234
+ emit("update:modelValue", updateFieldAt(props.modelValue, realIndex, key, val));
244
235
  }
245
236
  const jsonErrors = ref({});
246
237
  function jsonStr(v) {
@@ -1,4 +1,4 @@
1
- type Field = Record<string, unknown>;
1
+ import { type Field } from './docbuilderFields.js';
2
2
  type __VLS_Props = {
3
3
  modelValue: Field[];
4
4
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pure write helpers backing {@link DocBuilderFieldsPanel}. Extracted from the SFC so they are
3
+ * unit-testable in a plain node environment, matching {@link docbuilderActions}.
4
+ *
5
+ * These are hop 3 of the four that must each preserve keys the builder never displays: the graph
6
+ * edit rebuilds `workflow.actions`, the actions panel writes a cell, **this** writes a field cell,
7
+ * then the server merges onto what is on disk. The invariant has regressed twice upstream — once
8
+ * dropping `clientHandler`, once dropping `triggers` — both times because a hop enumerated the
9
+ * named keys it knew about instead of spreading whatever was there.
10
+ *
11
+ * This hop is the one with the widest blast radius, because the server replaces `fields` wholesale
12
+ * with whatever the client sends. A key dropped here is dropped on disk. On the FAB app that is
13
+ * `filterFunction` x7, `isAsync` x7, `collapsible` x4, nested `schema` x4, `options` x1 and
14
+ * `source` x325 — none of which the panel renders, all of which must survive editing a label.
15
+ */
16
+ /** A doctype field as authored on disk: known keys plus anything the builder does not model. */
17
+ export type Field = Record<string, unknown>;
18
+ /**
19
+ * Set `key` on `field`, or remove it when `val` is `undefined`.
20
+ *
21
+ * Clearing a cell must delete the key rather than write `undefined`: the doctype is serialised with
22
+ * `JSON.stringify`, which drops `undefined` values silently, so writing one would make "cleared" and
23
+ * "never set" indistinguishable on disk while still differing in memory.
24
+ */
25
+ export declare function setOrDelete(field: Field, key: string, val: unknown): Field;
26
+ /**
27
+ * Rebuild the full field array with one cell changed at `realIndex`.
28
+ *
29
+ * Rebuilds every entry by index rather than splicing, so fields at other indices are returned
30
+ * untouched and order is preserved. An out-of-range index is a no-op returning an equal array.
31
+ */
32
+ export declare function updateFieldAt(fields: readonly Field[], realIndex: number, key: string, val: unknown): Field[];
@@ -0,0 +1,10 @@
1
+ export function setOrDelete(field, key, val) {
2
+ if (val === void 0) {
3
+ const { [key]: _omit, ...rest } = field;
4
+ return rest;
5
+ }
6
+ return { ...field, [key]: val };
7
+ }
8
+ export function updateFieldAt(fields, realIndex, key, val) {
9
+ return fields.map((f, i) => i === realIndex ? setOrDelete(f, key, val) : f);
10
+ }
@@ -1,30 +1,13 @@
1
- import type { ActionEventPayload } from '@stonecrop/desktop';
2
1
  /**
3
- * Result of dispatching an action to its server handler.
4
- */
5
- export type ActionDispatchResult = {
6
- success: boolean;
7
- data: unknown;
8
- error: string | null;
9
- };
10
- /**
11
- * Shared executor for doctype action clicks. A host's Desktop `@action` handler delegates
12
- * here so every host runs the same logic from one definition:
13
- *
14
- * - If the clicked action carries a `clientHandler`, run it. The handler **owns
15
- * orchestration** — it calls `runAction` itself when it needs the server, navigates via
16
- * `router`, reads `record`, or queries `graphql`. It supersedes the default dispatch.
17
- * - Otherwise dispatch the action to its server `handler` (the pre-existing behavior),
18
- * so actions without a `clientHandler` are unchanged.
2
+ * The shared action executor, re-exported so Nuxt hosts keep getting it as an auto-import.
19
3
  *
20
- * `runAction` is the only blessed write: it dispatches **and** writes the returned record
21
- * back into HST, keeping the store consistent — the same invariant the host handler
22
- * previously upheld inline (`addRecord(result.data)` after dispatch).
4
+ * It used to be defined here, which made the one blessed write path a Nuxt-only privilege while
5
+ * every other Vue host hand-rolled sixty lines of identity reconciliation — and both hosts that
6
+ * did got the create case wrong in the same way. Nothing in it was ever Nuxt-specific, so it now
7
+ * lives in `@stonecrop/stonecrop` alongside the store it writes to.
23
8
  *
24
- * The composable owns the `[{ id, data }]` argument envelope every server handler reads
25
- * (`const [{ id }] = args`), so an authored handler calls `runAction('Assign')` without
26
- * knowing that shape; an optional second argument is merged in for handlers needing more.
9
+ * This file stays because `addImportsDir` scans this directory: deleting it would silently remove
10
+ * the auto-import that every scaffolded app's `@action="run"` binding depends on.
27
11
  */
28
- export declare function useClientAction(): {
29
- run: (payload: ActionEventPayload) => Promise<void>;
30
- };
12
+ export { useClientAction } from '@stonecrop/stonecrop';
13
+ export type { ActionArgsContext, ActionDispatchResult, ActionFailure, FollowRecordContext, UseClientActionOptions, } from '@stonecrop/stonecrop';
@@ -1,51 +1 @@
1
- import { executeClientHandler, useStonecrop } from "@stonecrop/stonecrop";
2
- import { useRouter } from "vue-router";
3
- function notifyActionError(message) {
4
- console.error("Action failed:", message);
5
- if (typeof window !== "undefined") window.alert(message);
6
- }
7
- export function useClientAction() {
8
- const { stonecrop } = useStonecrop();
9
- const router = useRouter();
10
- async function dispatchAndWriteback(doctypeSlug, recordId, data, action, extra) {
11
- const sc = stonecrop.value;
12
- if (!sc) return { success: false, data: null, error: "Stonecrop is not initialized" };
13
- const doctype = sc.registry.getDoctype(doctypeSlug);
14
- if (!doctype) return { success: false, data: null, error: `Unknown doctype: ${doctypeSlug}` };
15
- const result = await sc.dispatchAction(doctype, action, [{ id: recordId, data, ...extra ?? {} }]);
16
- if (result.success && result.data && recordId) {
17
- sc.addRecord(doctypeSlug, recordId, result.data);
18
- }
19
- return result;
20
- }
21
- async function run(payload) {
22
- const sc = stonecrop.value;
23
- if (!sc) return;
24
- const { name, doctype: doctypeSlug, recordId, data } = payload;
25
- const doctype = sc.registry.getDoctype(doctypeSlug);
26
- const workflow = doctype?.workflow;
27
- const clientHandler = workflow?.actions?.[name]?.clientHandler;
28
- try {
29
- if (!clientHandler) {
30
- const result = await dispatchAndWriteback(doctypeSlug, recordId, data, name);
31
- if (!result.success) notifyActionError(result.error ?? `Action "${name}" failed`);
32
- return;
33
- }
34
- const record = sc.getRecordById(doctypeSlug, recordId)?.get("") ?? data;
35
- const runAction = (action, extra) => dispatchAndWriteback(doctypeSlug, recordId, data, action, extra);
36
- const graphql = {
37
- query(query, variables) {
38
- const client = sc.getClient();
39
- if (!client?.query) {
40
- return Promise.reject(new Error("The configured data client does not support graphql.query"));
41
- }
42
- return client.query(query, variables);
43
- }
44
- };
45
- await executeClientHandler(clientHandler, { router, record, runAction, graphql });
46
- } catch (error) {
47
- notifyActionError(error instanceof Error ? error.message : String(error));
48
- }
49
- }
50
- return { run };
51
- }
1
+ export { useClientAction } from "@stonecrop/stonecrop";
@@ -95,6 +95,11 @@ export declare function useStonecropRegistry(): {
95
95
  * Dispatch an action to the server via the configured data client.
96
96
  * All state changes flow through this single mutation endpoint.
97
97
  *
98
+ * This is the raw dispatch: it returns the result and writes nothing to the store. Prefer
99
+ * `useClientAction`, which also runs a declared `clientHandler` and writes the result back
100
+ * under the identity the *server* settled on — storing it under the id you dispatched
101
+ * strands a newly created record under a key nothing can fetch.
102
+ *
98
103
  * @param doctype - Doctype reference object with `name` and optional `slug` properties
99
104
  * @param doctype.name - Doctype name (e.g., 'plan')
100
105
  * @param doctype.slug - Optional doctype slug if it differs from the name (e.g., 'project-plan')
@@ -121,7 +126,7 @@ export declare function useStonecropRegistry(): {
121
126
  }>;
122
127
  /**
123
128
  * Set the `getMeta` function on the Registry.
124
- * Called by `useStonecrop()` to lazy-load doctype metadata for the current route.
129
+ * Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
125
130
  *
126
131
  * You must bridge `RouteContext` → `DoctypeContext`:
127
132
  * - Extract doctype name from `segments` (e.g., `segments[0]`)
@@ -57,6 +57,11 @@ export function useStonecropRegistry() {
57
57
  * Dispatch an action to the server via the configured data client.
58
58
  * All state changes flow through this single mutation endpoint.
59
59
  *
60
+ * This is the raw dispatch: it returns the result and writes nothing to the store. Prefer
61
+ * `useClientAction`, which also runs a declared `clientHandler` and writes the result back
62
+ * under the identity the *server* settled on — storing it under the id you dispatched
63
+ * strands a newly created record under a key nothing can fetch.
64
+ *
60
65
  * @param doctype - Doctype reference object with `name` and optional `slug` properties
61
66
  * @param doctype.name - Doctype name (e.g., 'plan')
62
67
  * @param doctype.slug - Optional doctype slug if it differs from the name (e.g., 'project-plan')
@@ -93,7 +98,7 @@ export function useStonecropRegistry() {
93
98
  },
94
99
  /**
95
100
  * Set the `getMeta` function on the Registry.
96
- * Called by `useStonecrop()` to lazy-load doctype metadata for the current route.
101
+ * Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
97
102
  *
98
103
  * You must bridge `RouteContext` → `DoctypeContext`:
99
104
  * - Extract doctype name from `segments` (e.g., `segments[0]`)
@@ -72,7 +72,7 @@ export declare function useStonecropSetup(): {
72
72
  registerClient(client: DataClient): void;
73
73
  /**
74
74
  * Set the `getMeta` function on the Registry.
75
- * Called by `useStonecrop()` to lazy-load doctype metadata for the current route.
75
+ * Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
76
76
  *
77
77
  * You must bridge `RouteContext` → `DoctypeContext`:
78
78
  * - Extract doctype name from `segments` (e.g., `segments[0]`)
@@ -47,7 +47,7 @@ export function useStonecropSetup() {
47
47
  },
48
48
  /**
49
49
  * Set the `getMeta` function on the Registry.
50
- * Called by `useStonecrop()` to lazy-load doctype metadata for the current route.
50
+ * Called by `useStonecrop({ doctype: 'slug' })` to lazy-load that doctype's metadata.
51
51
  *
52
52
  * You must bridge `RouteContext` → `DoctypeContext`:
53
53
  * - Extract doctype name from `segments` (e.g., `segments[0]`)
@@ -9,3 +9,28 @@
9
9
  * the intended one: re-imposing it keeps saves byte-stable while still honouring adds/removes.
10
10
  */
11
11
  export declare function orderKeysByReference<T>(next: Record<string, T> | undefined, reference: Record<string, T> | undefined): Record<string, T> | undefined;
12
+ /** The subset of the save request body this merge reads. Extra keys are ignored, not written. */
13
+ export interface SaveDoctypeBody {
14
+ fields: unknown[];
15
+ workflow?: unknown;
16
+ }
17
+ /**
18
+ * Build the object the docbuilder save writes to disk, from the file already there plus the
19
+ * builder's submission.
20
+ *
21
+ * This is the last of four hops that must each preserve keys the builder never displays — the
22
+ * graph edit, the actions-panel edit, the fields-panel edit, then this. The invariant has already
23
+ * regressed twice upstream (once dropping `clientHandler`, once dropping `triggers`), both times
24
+ * because a hop enumerated named keys instead of spreading, so it is worth stating exactly what
25
+ * this hop does and does not guarantee:
26
+ *
27
+ * - **Top-level keys survive** by spread. Anything on disk that the builder doesn't model —
28
+ * `links`, `primaryKey`, `source`, a consumer's own key — is carried through untouched.
29
+ * - **Field-level keys are NOT this hop's job.** `fields` is replaced wholesale with what the
30
+ * client sent, so a key dropped in the browser is dropped here too. Hop 3 owns that.
31
+ *
32
+ * @param existing - The parsed doctype already on disk, or `{}` when creating one
33
+ * @param body - The builder's submission
34
+ * @param requestedName - The slug the builder asked to save, used only as a `name` fallback
35
+ */
36
+ export declare function mergeSavedDoctype(existing: Record<string, unknown>, body: SaveDoctypeBody, requestedName: string): Record<string, unknown>;
@@ -10,3 +10,21 @@ export function orderKeysByReference(next, reference) {
10
10
  }
11
11
  return result;
12
12
  }
13
+ export function mergeSavedDoctype(existing, body, requestedName) {
14
+ const doctypeData = {
15
+ ...existing,
16
+ fields: body.fields
17
+ };
18
+ if (body.workflow !== void 0 && body.workflow !== null) {
19
+ const existingActions = existing.workflow?.actions;
20
+ const workflow = body.workflow;
21
+ doctypeData.workflow = workflow.actions ? { ...workflow, actions: orderKeysByReference(workflow.actions, existingActions) } : workflow;
22
+ } else if (doctypeData.workflow === null || doctypeData.workflow === void 0) {
23
+ delete doctypeData.workflow;
24
+ }
25
+ if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
26
+ doctypeData.name = requestedName;
27
+ }
28
+ delete doctypeData.schema;
29
+ return doctypeData;
30
+ }
@@ -3,7 +3,7 @@ import { readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { createError, defineEventHandler, readBody } from "h3";
5
5
  import { useRuntimeConfig } from "#imports";
6
- import { orderKeysByReference } from "./mergeDoctype.js";
6
+ import { mergeSavedDoctype } from "./mergeDoctype.js";
7
7
  export default defineEventHandler(async (event) => {
8
8
  const body = await readBody(event);
9
9
  if (!body.doctype || typeof body.doctype !== "string") {
@@ -50,21 +50,7 @@ export default defineEventHandler(async (event) => {
50
50
  } catch {
51
51
  }
52
52
  }
53
- const doctypeData = {
54
- ...existing,
55
- fields: body.fields
56
- };
57
- if (body.workflow !== void 0 && body.workflow !== null) {
58
- const existingActions = existing.workflow?.actions;
59
- const workflow = body.workflow;
60
- doctypeData.workflow = workflow.actions ? { ...workflow, actions: orderKeysByReference(workflow.actions, existingActions) } : workflow;
61
- } else if (doctypeData.workflow === null || doctypeData.workflow === void 0) {
62
- delete doctypeData.workflow;
63
- }
64
- if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
65
- doctypeData.name = requested;
66
- }
67
- delete doctypeData.schema;
53
+ const doctypeData = mergeSavedDoctype(existing, body, requested);
68
54
  try {
69
55
  await writeFile(filePath, JSON.stringify(doctypeData, null, " ") + "\n", "utf-8");
70
56
  return { success: true, path: `doctypes/${basename(filePath)}` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/nuxt",
3
- "version": "0.16.6",
3
+ "version": "0.18.0",
4
4
  "description": "Nuxt module for Stonecrop",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,18 +45,18 @@
45
45
  "pathe": "^2.0.3",
46
46
  "pinia": "^3.0.4",
47
47
  "prompts": "^2.4.2",
48
- "@stonecrop/aform": "0.16.6",
49
- "@stonecrop/desktop": "0.16.6",
50
- "@stonecrop/casl-middleware": "0.16.6",
51
- "@stonecrop/graphql-client": "0.16.6",
52
- "@stonecrop/code-editor": "0.16.6",
53
- "@stonecrop/graphql-middleware": "0.16.6",
54
- "@stonecrop/node-editor": "0.16.6",
55
- "@stonecrop/schema": "0.16.6",
56
- "@stonecrop/nuxt-grafserv": "0.16.6",
57
- "@stonecrop/stonecrop": "0.16.6",
58
- "@stonecrop/themes": "0.16.6",
59
- "@stonecrop/atable": "0.16.6"
48
+ "@stonecrop/aform": "0.18.0",
49
+ "@stonecrop/casl-middleware": "0.18.0",
50
+ "@stonecrop/atable": "0.18.0",
51
+ "@stonecrop/code-editor": "0.18.0",
52
+ "@stonecrop/desktop": "0.18.0",
53
+ "@stonecrop/graphql-client": "0.18.0",
54
+ "@stonecrop/node-editor": "0.18.0",
55
+ "@stonecrop/graphql-middleware": "0.18.0",
56
+ "@stonecrop/nuxt-grafserv": "0.18.0",
57
+ "@stonecrop/schema": "0.18.0",
58
+ "@stonecrop/stonecrop": "0.18.0",
59
+ "@stonecrop/themes": "0.18.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@eslint/js": "^10.0.1",
@@ -103,7 +103,7 @@
103
103
  "prepublish": "rushx dev:prepare && nuxt-module-build build",
104
104
  "test": "vitest run",
105
105
  "test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul",
106
- "test:types": "vue-tsc --noEmit -p tsconfig.runtime.json && cd playground && vue-tsc --noEmit && cd ../fullstack && vue-tsc --noEmit && vue-tsc --noEmit -p server/tsconfig.json",
106
+ "test:types": "vue-tsc --noEmit -p tsconfig.runtime.json && vue-tsc --noEmit -p tsconfig.templates-app.json && vue-tsc --noEmit -p tsconfig.templates-server.json && cd playground && vue-tsc --noEmit && cd ../fullstack && vue-tsc --noEmit && vue-tsc --noEmit -p server/tsconfig.json",
107
107
  "test:ui": "vitest --ui",
108
108
  "test:watch": "vitest watch"
109
109
  }
@@ -39,7 +39,8 @@
39
39
  "actions": {
40
40
  "save": {
41
41
  "label": "Save",
42
- "stateless": true
42
+ "stateless": true,
43
+ "selfTransition": true
43
44
  },
44
45
  "archive": {
45
46
  "label": "Archive Project",
@@ -51,6 +51,11 @@
51
51
  "actions": {
52
52
  "save": {
53
53
  "label": "Save",
54
+ "stateless": true,
55
+ "selfTransition": true
56
+ },
57
+ "snooze": {
58
+ "label": "Snooze a Week",
54
59
  "stateless": true
55
60
  },
56
61
  "start_task": {
@@ -1,11 +1,6 @@
1
1
  <template>
2
2
  <ClientOnly>
3
- <Desktop
4
- :available-doctypes="availableDoctypes"
5
- :route-adapter="routeAdapter"
6
- @action="handleAction"
7
- @load-records="handleLoadRecords"
8
- @load-record="handleLoadRecord" />
3
+ <Desktop :available-doctypes="availableDoctypes" :route-adapter="routeAdapter" @action="run" />
9
4
  <template #fallback>
10
5
  <div class="sc-loading">
11
6
  <p>Loading...</p>
@@ -15,76 +10,24 @@
15
10
  </template>
16
11
 
17
12
  <script setup lang="ts">
18
- import {
19
- Desktop,
20
- type ActionEventPayload,
21
- type LoadRecordEventPayload,
22
- type LoadRecordsEventPayload,
23
- } from '@stonecrop/desktop'
24
- import { useStonecrop } from '@stonecrop/stonecrop'
13
+ import { Desktop } from '@stonecrop/desktop'
25
14
 
26
15
  import { useRouteAdapter } from '~/composables/useRouteAdapter'
27
- import {
28
- doctypeMap,
29
- useDoctypeConfig,
30
- fetchDoctypeRecords,
31
- fetchDoctypeRecord,
32
- runDoctypeAction,
33
- } from '~/composables/useDoctypes'
16
+ import { doctypeMap } from '~/composables/useDoctypes'
34
17
 
35
18
  const routeAdapter = useRouteAdapter()
36
- const { stonecrop } = useStonecrop()
37
-
19
+ // Shared action executor, auto-imported from @stonecrop/nuxt: it runs an action's clientHandler
20
+ // when the doctype declares one, otherwise dispatches to the server, and writes the result back
21
+ // into the store under the identity the server settled on — a Save against a record that does not
22
+ // exist creates it, so that identity is not always the one the form dispatched. Bound straight to
23
+ // Desktop's @action; a host-written wrapper would have to restate all of that.
24
+ const { run } = useClientAction()
25
+
26
+ // Reads are not bound here either. The StonecropClient registered in `stonecrop.client.ts` is this
27
+ // app's whole data layer: Stonecrop fetches through it and keys each result by the doctype's
28
+ // declared identity. A handler here would only race that fetch with a second copy of the same rule,
29
+ // which is how a hardcoded `record.id` once dropped every row of a natural-keyed doctype.
38
30
  const availableDoctypes = computed(() => Array.from(doctypeMap.keys()))
39
-
40
- async function handleLoadRecords(payload: LoadRecordsEventPayload) {
41
- const doctypeConfig = useDoctypeConfig(payload.doctype)
42
- if (!doctypeConfig || !stonecrop.value) return
43
-
44
- try {
45
- const { data } = await fetchDoctypeRecords({ name: doctypeConfig.name })
46
- for (const record of data) {
47
- const recordId = record.id as string
48
- if (recordId) stonecrop.value.addRecord(payload.doctype, recordId, record)
49
- }
50
- } catch (error) {
51
- console.error('Failed to load records:', error)
52
- }
53
- }
54
-
55
- async function handleLoadRecord(payload: LoadRecordEventPayload) {
56
- if (!stonecrop.value || payload.recordId.startsWith('new-')) return
57
-
58
- const doctypeConfig = useDoctypeConfig(payload.doctype)
59
- if (!doctypeConfig) return
60
-
61
- try {
62
- const record = await fetchDoctypeRecord({ name: doctypeConfig.name }, payload.recordId)
63
- if (record) stonecrop.value.addRecord(payload.doctype, payload.recordId, record)
64
- } catch (error) {
65
- console.error('Failed to load record:', error)
66
- }
67
- }
68
-
69
- async function handleAction(payload: ActionEventPayload) {
70
- const doctypeConfig = useDoctypeConfig(payload.doctype)
71
- if (!doctypeConfig) return
72
-
73
- try {
74
- const result = await runDoctypeAction(doctypeConfig, payload.name, {
75
- id: payload.recordId,
76
- data: payload.data,
77
- })
78
-
79
- if (result.success && result.data && stonecrop.value && payload.recordId) {
80
- stonecrop.value.addRecord(payload.doctype, payload.recordId, result.data as Record<string, unknown>)
81
- }
82
-
83
- if (!result.success) console.error('Action failed:', result.error)
84
- } catch (error) {
85
- console.error('Action error:', error)
86
- }
87
- }
88
31
  </script>
89
32
 
90
33
  <style>
@@ -5,7 +5,10 @@
5
5
  * @see https://grafast.org/grafserv/plugins
6
6
  */
7
7
 
8
- import type { GraphileConfig } from 'graphile-config'
8
+ // `GraphileConfig` is an ambient global declared by graphile-config's own types, so it needs no
9
+ // import. The `import type { GraphileConfig } from 'graphile-config'` that used to be here never
10
+ // resolved — graphile-config is an optional peer dependency, so it is not installed — and an
11
+ // unresolved type silently degrades everything annotated with it to `any`.
9
12
 
10
13
  /**
11
14
  * Example: Request logging plugin
@@ -17,9 +20,13 @@ const loggingPlugin: GraphileConfig.Plugin = {
17
20
  middleware: {
18
21
  processGraphQLRequestBody: async (next, event) => {
19
22
  const start = Date.now()
23
+
24
+ // `event.request` is optional: a GraphQL-over-WebSocket message carries no HTTP request.
25
+ // It is grafserv's own RequestDigest, not a Web `Request` — the path lives on `path`
26
+ // (there is no `url`), and headers are read with `getHeader()`, not `headers.get()`.
20
27
  console.log('[GraphQL] Request started:', {
21
- path: event.request.url,
22
- method: event.request.method,
28
+ path: event.request?.path,
29
+ method: event.request?.method,
23
30
  })
24
31
 
25
32
  const result = await next()
@@ -42,8 +49,9 @@ const _authPlugin: GraphileConfig.Plugin = {
42
49
  grafserv: {
43
50
  middleware: {
44
51
  processGraphQLRequestBody: async (next, event) => {
45
- // Extract authentication from headers
46
- const authHeader = event.request.headers.get('authorization')
52
+ // Extract authentication from headers. See the logging plugin above for why `request`
53
+ // is optional and why this is `getHeader()` rather than `headers.get()`.
54
+ const authHeader = event.request?.getHeader('authorization')
47
55
 
48
56
  if (authHeader?.startsWith('Bearer ')) {
49
57
  const token = authHeader.slice(7)
@@ -60,18 +68,29 @@ const _authPlugin: GraphileConfig.Plugin = {
60
68
  }
61
69
 
62
70
  /**
63
- * Export all plugins
64
- * Import these in your nuxt.config.ts:
71
+ * Export all plugins.
72
+ *
73
+ * Reference them from your PostGraphile preset file — `grafserv.preset` is a **path to that file**,
74
+ * not an inline object. Inline presets are not supported: Nitro's build/runtime split cannot
75
+ * serialize the functions and plugin instances a preset contains.
76
+ *
77
+ * ```ts
78
+ * // server/graphile.preset.ts
79
+ * import { createStonecropPreset, makePgService, createStonecropPlugin } from '@stonecrop/graphql-middleware'
80
+ * import plugins from './plugins'
65
81
  *
66
- * import plugins from './server/plugins'
82
+ * export default {
83
+ * extends: [createStonecropPreset()],
84
+ * pgServices: [makePgService({ connectionString: process.env.DATABASE_URL, schemas: ['public'] })],
85
+ * plugins: [createStonecropPlugin(), ...plugins],
86
+ * }
67
87
  *
88
+ * // nuxt.config.ts
68
89
  * export default defineNuxtConfig({
69
- * grafserv: {
70
- * preset: {
71
- * plugins
72
- * }
73
- * }
90
+ * modules: ['@stonecrop/nuxt-grafserv'],
91
+ * grafserv: { type: 'postgraphile', preset: './server/graphile.preset.ts' },
74
92
  * })
93
+ * ```
75
94
  */
76
95
  export const plugins: GraphileConfig.Plugin[] = [
77
96
  loggingPlugin,
@@ -9,9 +9,10 @@
9
9
  * - loadOne($step, fn) — batch-load data ASYNCHRONOUSLY (for DB queries)
10
10
  * - object({ ... }) — group multiple steps into a single step object
11
11
  *
12
- * Data reads go through loadOne. Workflow state transitions are applied by the
13
- * stonecropAction resolver itself (server-owns-transition, guarded by allowedStates);
14
- * side-effecting saves go through registered handlers (project:save, task:save).
12
+ * Data reads go through loadOne. Workflow outcomes are applied by the stonecropAction
13
+ * resolver itself (server-owns-transition, guarded by allowedStates), and anything a
14
+ * doctype cannot express — a Command with no state change — goes through the
15
+ * `actionHandlers` map below.
15
16
  *
16
17
  * To connect a real database, replace the imports from ./data with your
17
18
  * PostGraphile setup. See: https://stonecrop.io/docs/guides/postgraphile
@@ -19,7 +20,7 @@
19
20
 
20
21
  import { constant, lambda, loadOne, object } from 'grafast'
21
22
  import { getMeta, getAllMeta, applyGuardedTransition } from '@stonecrop/graphql-middleware'
22
- import { getPrimaryKeyField } from '@stonecrop/schema'
23
+ import { getRecordIdField } from '@stonecrop/schema'
23
24
  import type { DoctypeMeta } from '@stonecrop/schema'
24
25
  import { projects, tasks, type Project, type Task } from './data'
25
26
 
@@ -68,12 +69,13 @@ export function formatDoctypeMeta(meta: DoctypeMeta) {
68
69
  *
69
70
  * The `id` fallback covers doctypes that declare no `primaryKey`. Every doctype in this repo now
70
71
  * declares one, enforced by test/doctype-fixtures.test.ts, so in-repo the fallback is inert.
71
- * It stays for consumer doctypes that have not adopted the rule: the Postgres adapter refuses
72
- * those outright (`data: null`), and this host stays permissive. The conformance suite records
73
- * that difference rather than asserting one answer.
72
+ * It stays for consumer doctypes that have not adopted the rule, and every host now applies it —
73
+ * the Postgres adapter used to omit it and refuse those doctypes outright, which is what made the
74
+ * hosts disagree. This is a thin wrapper on the shared rule, kept for the `meta`-shaped signature
75
+ * the resolvers call it with.
74
76
  */
75
77
  export function recordLookupField(meta: DoctypeMeta): string {
76
- return getPrimaryKeyField(meta.fields)?.fieldname ?? 'id'
78
+ return getRecordIdField(meta.fields)
77
79
  }
78
80
 
79
81
  function getRecord(doctype: string, id: string, lookupField = 'id'): Project | Task | null {
@@ -91,6 +93,19 @@ function getRecord(doctype: string, id: string, lookupField = 'id'): Project | T
91
93
  return null
92
94
  }
93
95
 
96
+ /**
97
+ * Write a record into its store under `key`.
98
+ *
99
+ * `key` is the record's identity under the doctype's declared `primaryKey` — the same field
100
+ * `getRecord` matches against — so the store is keyed by whatever the doctype says identifies a
101
+ * record, not by a hardcoded `id`. Both doctypes here declare `id`, so the two coincide today.
102
+ */
103
+ function setRecord(doctype: string, key: string, record: Record<string, unknown>): void {
104
+ const d = doctype.toLowerCase()
105
+ if (d === 'project') projects.set(key, record as unknown as Project)
106
+ else if (d === 'task') tasks.set(key, record as unknown as Task)
107
+ }
108
+
94
109
  function getRecords(doctype: string, filters?: Record<string, unknown>): (Project | Task)[] {
95
110
  const d = doctype.toLowerCase()
96
111
  if (d === 'project') {
@@ -113,6 +128,51 @@ function nextId(doctype: string): string {
113
128
  return String(max + 1)
114
129
  }
115
130
 
131
+ // ============================================================
132
+ // Server-side action effects
133
+ // ============================================================
134
+ // A doctype's workflow says whether an action may run (`allowedStates`) and what state results
135
+ // (`nextState`, `selfTransition`). It deliberately says nothing about what the action *does*: a
136
+ // doctype is runtime data edited in DocBuilder, and it must not name server code a different
137
+ // author owns. So the routing from action to effect lives here, keyed `[doctype name][action
138
+ // key]`, and is never published to the client.
139
+ //
140
+ // This is what makes a Command executable at all. An action with no `nextState` and no
141
+ // `selfTransition` has nothing for the dispatcher to apply, and without an entry here it fails
142
+ // loudly rather than reporting a false success.
143
+ //
144
+ // Add your own by registering under the doctype's `name`. Throwing rejects the action; returning
145
+ // the updated record makes it the client writeback payload.
146
+
147
+ type ActionHandler = (context: {
148
+ recordId?: string
149
+ /** Record field data the client sent. Unvalidated browser input — validate before trusting it. */
150
+ data: Record<string, unknown>
151
+ /** The state the guard read, or undefined when nothing about the action required reading it. */
152
+ currentState?: string
153
+ }) => Promise<unknown>
154
+
155
+ export const actionHandlers: Record<string, Record<string, ActionHandler>> = {
156
+ Task: {
157
+ /**
158
+ * Push the due date out by a week.
159
+ *
160
+ * The example is deliberately a *stateless* command: snoozing does not move the task
161
+ * through its workflow, so there is no state for the doctype to declare — only an effect.
162
+ */
163
+ async snooze({ recordId }) {
164
+ const task = recordId != null ? tasks.get(recordId) : undefined
165
+ if (!task) throw new Error(`Task ${recordId ?? '(none)'} not found`)
166
+
167
+ const from = task.dueDate ? new Date(task.dueDate) : new Date()
168
+ from.setDate(from.getDate() + 7)
169
+ const updated: Task = { ...task, dueDate: from.toISOString().slice(0, 10) }
170
+ tasks.set(task.id, updated)
171
+ return Promise.resolve(updated)
172
+ },
173
+ },
174
+ }
175
+
116
176
  // ============================================================
117
177
  // Resolvers (Grafast plan format)
118
178
  // ============================================================
@@ -154,7 +214,7 @@ export const resolvers = {
154
214
  })
155
215
  },
156
216
 
157
- stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $options }: any) {
217
+ stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $includeTotal, $options }: any) {
158
218
  return loadOne(
159
219
  object({
160
220
  doctype: $doctype,
@@ -162,6 +222,7 @@ export const resolvers = {
162
222
  orderBy: $orderBy,
163
223
  limit: $limit,
164
224
  offset: $offset,
225
+ includeTotal: $includeTotal,
165
226
  options: $options,
166
227
  }),
167
228
  async (specs: readonly any[]) => {
@@ -169,10 +230,15 @@ export const resolvers = {
169
230
  const all = getRecords(spec.doctype, spec.filters ?? {})
170
231
  const offset = spec.offset ?? 0
171
232
  const limit = spec.limit ?? 100
233
+ const page = all.slice(offset, offset + limit)
172
234
  return {
173
- data: all.slice(offset, offset + limit),
235
+ data: page,
174
236
  doctype: spec.doctype,
175
- count: all.length,
237
+ hasMore: offset + page.length < all.length,
238
+ // This store is in memory, so counting is free — but it stays opt-in anyway,
239
+ // because the scaffold is what a real adapter gets copied from and a backend
240
+ // that answers a total nobody asked for teaches the wrong default.
241
+ count: spec.includeTotal === true ? all.length : null,
176
242
  }
177
243
  })
178
244
  }
@@ -201,6 +267,11 @@ export const resolvers = {
201
267
  const recordId = argList[0]?.id != null ? String(argList[0].id) : undefined
202
268
  const recordData: Record<string, unknown> = argList[0]?.data ?? {}
203
269
  const d = spec.doctype.toLowerCase()
270
+ const handler = actionHandlers[meta.name]?.[String(spec.action)]
271
+ // The field a record is identified by. The read path already resolved records
272
+ // through this; the action path used to assume `id`, so an action on a
273
+ // natural-keyed doctype looked up a key the client never sent.
274
+ const lookupField = recordLookupField(meta)
204
275
 
205
276
  try {
206
277
  // The server owns the transition: read current state, guard against allowedStates,
@@ -210,28 +281,61 @@ export const resolvers = {
210
281
  actionDef,
211
282
  {
212
283
  readState: async () => {
213
- if (recordId == null) return undefined
214
- const record = getRecord(d, recordId)
215
- return record?.status == null ? undefined : String(record.status)
284
+ // `null` means "no such record" and `undefined` means "exists, no
285
+ // state" — the dispatcher rejects the first outright. Returning
286
+ // `undefined` for both is what let a Save on a record that was
287
+ // never created report success while persisting nothing.
288
+ if (recordId == null) return null
289
+ const record = getRecord(d, recordId, lookupField)
290
+ if (!record) return null
291
+ return record.status == null ? undefined : String(record.status)
216
292
  },
217
293
  writeState: async (nextState: string) => {
218
294
  if (recordId == null) return
219
- const existing = getRecord(d, recordId)
295
+ const existing = getRecord(d, recordId, lookupField)
220
296
  if (!existing) return
221
- if (d === 'project') projects.set(recordId, { ...existing, status: nextState } as Project)
222
- else if (d === 'task') tasks.set(recordId, { ...existing, status: nextState } as Task)
297
+ setRecord(d, recordId, { ...existing, status: nextState })
223
298
  },
224
- // Self-transition data write: merge the edited fields into the record (status
225
- // untouched) and return the full record for the client writeback.
226
- writeData: async (patch: Record<string, unknown>) => {
227
- if (recordId == null) return {}
228
- const existing = getRecord(d, recordId)
229
- if (!existing) return {}
230
- const updated = { ...existing, ...patch }
231
- if (d === 'project') projects.set(recordId, updated as Project)
232
- else if (d === 'task') tasks.set(recordId, updated as Task)
233
- return updated as Record<string, unknown>
299
+ // Save is an upsert, and it is the only write path — there is no create action
300
+ // and no create mutation. Updating merges the edited fields in place (status
301
+ // untouched); creating derives the identity from the doctype's declared
302
+ // primary key when the submitted data carries it, which is how a
303
+ // natural-keyed doctype is identified, and mints one only otherwise.
304
+ // Either way the full record comes back for the client writeback.
305
+ writeData: async (patch: Record<string, unknown>, exists: boolean) => {
306
+ if (exists) {
307
+ if (recordId == null) return {}
308
+ const existing = getRecord(d, recordId, lookupField)
309
+ if (!existing) return {}
310
+ const updated = { ...existing, ...patch }
311
+ setRecord(d, recordId, updated)
312
+ return updated as Record<string, unknown>
313
+ }
314
+
315
+ const declared = patch[lookupField]
316
+ const identity =
317
+ typeof declared === 'string' && declared !== ''
318
+ ? declared
319
+ : typeof declared === 'number'
320
+ ? String(declared)
321
+ : nextId(d)
322
+ const defaults =
323
+ d === 'project'
324
+ ? { status: 'Active', description: '' }
325
+ : { status: 'Todo', description: '', dueDate: null }
326
+ const record = {
327
+ ...defaults,
328
+ ...patch,
329
+ [lookupField]: identity,
330
+ createdAt: new Date().toISOString(),
331
+ }
332
+ setRecord(d, identity, record)
333
+ return record
234
334
  },
335
+ // The server-owned effect for this action, if one is registered above.
336
+ runEffect: handler
337
+ ? (currentState: string | undefined) => handler({ recordId, data: recordData, currentState })
338
+ : undefined,
235
339
  },
236
340
  recordData
237
341
  )
@@ -243,64 +347,6 @@ export const resolvers = {
243
347
  }
244
348
  )
245
349
  },
246
-
247
- stonecropCreate(_: unknown, { $doctype, $input }: any) {
248
- return loadOne(object({ doctype: $doctype, input: $input }), async (specs: readonly any[]) => {
249
- return specs.map(spec => {
250
- const d = spec.doctype.toLowerCase()
251
- const id = nextId(d)
252
- const now = new Date().toISOString()
253
- if (d === 'project') {
254
- const record: Project = { id, createdAt: now, status: 'Active', description: '', ...spec.input }
255
- projects.set(id, record)
256
- return { data: record, doctype: spec.doctype }
257
- }
258
- if (d === 'task') {
259
- const record: Task = { id, createdAt: now, status: 'Todo', description: '', dueDate: null, ...spec.input }
260
- tasks.set(id, record)
261
- return { data: record, doctype: spec.doctype }
262
- }
263
- return { data: null, doctype: spec.doctype }
264
- })
265
- })
266
- },
267
-
268
- stonecropUpdate(_: unknown, { $doctype, $id, $patch }: any) {
269
- return loadOne(object({ doctype: $doctype, id: $id, patch: $patch }), async (specs: readonly any[]) => {
270
- return specs.map(spec => {
271
- const d = spec.doctype.toLowerCase()
272
- const existing = getRecord(d, spec.id)
273
- if (!existing) return null
274
- if (d === 'project') {
275
- const updated = { ...existing, ...spec.patch } as Project
276
- projects.set(spec.id, updated)
277
- return { data: updated, doctype: spec.doctype }
278
- }
279
- if (d === 'task') {
280
- const updated = { ...existing, ...spec.patch } as Task
281
- tasks.set(spec.id, updated)
282
- return { data: updated, doctype: spec.doctype }
283
- }
284
- return null
285
- })
286
- })
287
- },
288
-
289
- stonecropDelete(_: unknown, { $doctype, $id }: any) {
290
- return loadOne(object({ doctype: $doctype, id: $id }), async (specs: readonly any[]) => {
291
- return specs.map(spec => {
292
- const d = spec.doctype.toLowerCase()
293
- let deleted = false
294
- if (d === 'project') deleted = projects.delete(spec.id)
295
- else if (d === 'task') deleted = tasks.delete(spec.id)
296
- return {
297
- success: deleted,
298
- data: deleted ? { id: spec.id } : null,
299
- error: deleted ? null : 'Record not found',
300
- }
301
- })
302
- })
303
- },
304
350
  },
305
351
  },
306
352
  }
@@ -89,7 +89,10 @@ type RecordResult {
89
89
  type RecordsResult {
90
90
  data: [JSON!]!
91
91
  doctype: String!
92
- count: Int!
92
+ "Whether further records exist beyond this page."
93
+ hasMore: Boolean!
94
+ "Total matching the filters. Null unless the query asked for it with includeTotal."
95
+ count: Int
93
96
  }
94
97
 
95
98
  type ActionResult {
@@ -137,6 +140,7 @@ type Query {
137
140
  orderBy: String
138
141
  limit: Int
139
142
  offset: Int
143
+ includeTotal: Boolean
140
144
  options: JSON
141
145
  ): RecordsResult
142
146
  }
@@ -150,19 +154,4 @@ type Mutation {
150
154
  Execute a doctype action
151
155
  """
152
156
  stonecropAction(doctype: String!, action: String!, args: JSON): ActionResult!
153
-
154
- """
155
- Create a new record
156
- """
157
- stonecropCreate(doctype: String!, input: JSON!): RecordResult!
158
-
159
- """
160
- Update an existing record
161
- """
162
- stonecropUpdate(doctype: String!, id: String!, patch: JSON!): RecordResult
163
-
164
- """
165
- Delete a record
166
- """
167
- stonecropDelete(doctype: String!, id: String!): ActionResult!
168
157
  }
@@ -1,6 +1,4 @@
1
- import type { DoctypeRef } from '@stonecrop/schema'
2
1
  import type { DoctypeConfig } from '@stonecrop/stonecrop'
3
- import { useNuxtApp } from 'nuxt/app'
4
2
 
5
3
  const modules = import.meta.glob<DoctypeConfig>('../../doctypes/*.json', {
6
4
  eager: true,
@@ -22,32 +20,15 @@ export function useDoctypeConfig(slug: string): DoctypeConfig | undefined {
22
20
  return doctypeMap.get(slug)
23
21
  }
24
22
 
25
- export async function fetchDoctypeRecords(doctype: DoctypeRef, limit = 200): Promise<{ data: any[]; count: number }> {
26
- const { $stonecropClient } = useNuxtApp()
27
- const data = (await $stonecropClient.getRecords({ name: doctype.name }, { limit })) as any[]
28
- return { data, count: data.length }
29
- }
30
-
31
- export async function fetchDoctypeRecord(
32
- doctype: DoctypeRef,
33
- recordId: string
34
- ): Promise<Record<string, unknown> | null> {
35
- const { $stonecropClient } = useNuxtApp()
36
- const result = await $stonecropClient.getRecord(doctype, recordId)
37
- return result.record
38
- }
39
-
40
- export interface ActionResult {
41
- success: boolean
42
- data?: unknown
43
- error?: string | null
44
- }
45
-
46
- export async function runDoctypeAction(
47
- doctype: DoctypeConfig,
48
- action: string,
49
- args: { id: string; data?: Record<string, unknown> }
50
- ): Promise<ActionResult> {
51
- const { $stonecropClient } = useNuxtApp()
52
- return $stonecropClient.runAction({ name: doctype.name }, action, [args])
53
- }
23
+ // There are deliberately no fetch helpers here. Fetching is not the whole job: the result has to
24
+ // land in the store under the identity the doctype declares, and something has to decide whether a
25
+ // read is warranted at all. `Stonecrop.getRecord`/`getRecords` own all of it and reach your backend
26
+ // through the client registered in `stonecrop.client.ts` — call those instead.
27
+ //
28
+ // The pair that used to live here also hardcoded `limit = 200`, which is a decision about what the
29
+ // backend can afford and therefore the server's to make, not a page's.
30
+
31
+ // Actions are deliberately not dispatched from here. Dispatching is only half the job: the result
32
+ // has to land in the store under the identity the server settled on, which is not always the id
33
+ // that was dispatched — a Save against a record that does not exist creates one. `useClientAction`
34
+ // (auto-imported from @stonecrop/nuxt) owns both halves; app/pages/index.vue binds it directly.