@marktiderman/genesis-core 1.4.0 → 1.5.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.
Files changed (52) hide show
  1. package/README.md +81 -0
  2. package/dist/{chunk-6MBACQYA.js → chunk-5NHR6ZXS.js} +10 -2
  3. package/dist/chunk-5NHR6ZXS.js.map +1 -0
  4. package/dist/chunk-BQOJX67X.js +31 -0
  5. package/dist/chunk-BQOJX67X.js.map +1 -0
  6. package/dist/{chunk-P4OIIRQS.js → chunk-DVW5GXWZ.js} +70 -26
  7. package/dist/chunk-DVW5GXWZ.js.map +1 -0
  8. package/dist/chunk-HT43MPKW.js +3 -0
  9. package/dist/{chunk-EL7YGOTY.js.map → chunk-HT43MPKW.js.map} +1 -1
  10. package/dist/chunk-HUNGCTRA.js +33 -0
  11. package/dist/chunk-HUNGCTRA.js.map +1 -0
  12. package/dist/{chunk-IHWWJUEN.js → chunk-IZT2O57C.js} +127 -39
  13. package/dist/chunk-IZT2O57C.js.map +1 -0
  14. package/dist/chunk-QH4P5MI2.js +278 -0
  15. package/dist/chunk-QH4P5MI2.js.map +1 -0
  16. package/dist/{chunk-NW26Z3LZ.js → chunk-RLY7TZUY.js} +178 -37
  17. package/dist/chunk-RLY7TZUY.js.map +1 -0
  18. package/dist/chunk-XQO4OH2N.js +32 -0
  19. package/dist/chunk-XQO4OH2N.js.map +1 -0
  20. package/dist/hooks/index.d.ts +5 -1
  21. package/dist/hooks/index.js +8 -4
  22. package/dist/hooks/mutation-mode.d.ts +109 -0
  23. package/dist/hooks/mutation-mode.js +3 -0
  24. package/dist/hooks/mutation-mode.js.map +1 -0
  25. package/dist/hooks/related-keys.d.ts +2 -1
  26. package/dist/hooks/use-capabilities.d.ts +21 -0
  27. package/dist/hooks/use-capabilities.js +5 -0
  28. package/dist/hooks/use-capabilities.js.map +1 -0
  29. package/dist/hooks/use-one.d.ts +2 -1
  30. package/dist/hooks/use-resource-form.d.ts +35 -2
  31. package/dist/hooks/use-resource-form.js +5 -2
  32. package/dist/hooks/use-resource.d.ts +29 -5
  33. package/dist/hooks/use-resource.js +2 -1
  34. package/dist/hooks/use-unsaved-changes-guard.d.ts +33 -0
  35. package/dist/hooks/use-unsaved-changes-guard.js +3 -0
  36. package/dist/hooks/use-unsaved-changes-guard.js.map +1 -0
  37. package/dist/index.d.ts +7 -3
  38. package/dist/index.js +10 -6
  39. package/dist/provider/index.d.ts +77 -5
  40. package/dist/provider/index.js +4 -2
  41. package/dist/read-outcome-BQkYcV4t.d.ts +88 -0
  42. package/dist/schema/index.d.ts +4 -3
  43. package/dist/schema/index.js +2 -1
  44. package/dist/{types-zILrlHuV.d.ts → types-C5bjsbxz.d.ts} +12 -279
  45. package/dist/types-Dsqqr9s7.d.ts +281 -0
  46. package/package.json +1 -1
  47. package/dist/chunk-6MBACQYA.js.map +0 -1
  48. package/dist/chunk-EL7YGOTY.js +0 -3
  49. package/dist/chunk-IHWWJUEN.js.map +0 -1
  50. package/dist/chunk-NW26Z3LZ.js.map +0 -1
  51. package/dist/chunk-P4OIIRQS.js.map +0 -1
  52. package/dist/write-outcome-BE3Tk6bA.d.ts +0 -47
package/README.md CHANGED
@@ -140,3 +140,84 @@ try {
140
140
  the root and `@marktiderman/genesis-core/schema`. Check with
141
141
  `isDataWriteError(error)` rather than `instanceof`, which fails when two copies
142
142
  of the package are installed.
143
+
144
+ ### Field-level errors
145
+
146
+ A `DataWriteError` may carry `fields`: field (column) name → a message safe to
147
+ show by that input. `useResourceForm` puts each one on its field
148
+ (`setError(field, { type: "server" })`), focuses the first, and sets
149
+ `errors.root` to the error's message as the summary; a field the form does not
150
+ show appears only in the summary.
151
+
152
+ `createSupabaseProvider` fills `fields` where Postgres says which column it
153
+ means (`fieldErrorsFromPostgrest`), and replaces the error's message with a
154
+ plain sentence (the raw text stays on `cause`):
155
+
156
+ | Code | Read from | Field message | Error message |
157
+ | --- | --- | --- | --- |
158
+ | `23505` unique | `details`: `Key (email)=(…) already exists.` | "This value is already in use." (several columns: "This combination is already in use.") | "Email is already in use." |
159
+ | `23502` not null | `message`: `null value in column "title" …` | "This field is required." | "Title is required." |
160
+ | `23503` foreign key (insert/update) | `details`: `Key (owner_id)=(…) is not present in table …` | "The selected item doesn't exist." | "Owner Id: the selected item doesn't exist." |
161
+
162
+ Only plain column names are taken from the store's text; the values it echoes
163
+ and constraint names never reach a message. Anything else (expression indexes,
164
+ check constraints) is left unmapped. Your own provider or `prepare` rule can
165
+ throw `new DataWriteError("invalid", "…", { fields: { slug: "Pick another slug." } })`.
166
+
167
+ ## Mutation modes
168
+
169
+ `useResource` (its `create`, `update`, `remove`) and `useResourceForm` take
170
+ `mutationMode`:
171
+
172
+ | Mode | Cache | Provider | Returned promise / `onSuccess` |
173
+ | --- | --- | --- | --- |
174
+ | `"pessimistic"` (default) | refetched after the write | at once | after the write |
175
+ | `"optimistic"` | patched at once; put back if the write fails | at once | at once |
176
+ | `"undoable"` | patched at once; put back on undo or failure | after `undoDelayMs` (default 5000) unless undone | at once |
177
+
178
+ - **Failures** in `optimistic` / `undoable` mode go to `onError` (the caller
179
+ was already told it worked, and the cache has been put back). Show them
180
+ there. `useResourceForm` also puts them on the form, if it is still open.
181
+ - **The undo control is yours.** Core has no UI: `onUndoable(write)` is called
182
+ with `{ resource, kind, id, strategy, delayMs, undo(), commit() }`.
183
+ genesis-ui's `ResourceForm` and `ResourcePage` show a sonner toast with an
184
+ Undo button.
185
+ - **A provider that serves `undo`** (`capabilities().undo === true` and an
186
+ `undo` method) gets an `undoable` write at once; `undo()` then calls
187
+ `provider.undo(resource, { auditId })` with the audit id the write returned
188
+ (`strategy: "provider"`). Without an audit id no undo is offered.
189
+ - **Leaving sends.** When the hook unmounts during the delay (the form closes,
190
+ the route changes), waiting writes are sent at once, as React Admin does.
191
+ Closing or reloading the tab also sends them, and holds the page with the
192
+ browser's prompt so the requests can finish.
193
+ - **What is patched:** an update merges the values into the row in every
194
+ cached list (`[resource, "list", …]`) and detail (`[resource, "detail", id,
195
+ …]`); a delete removes the row and lowers `total`. A create patches nothing
196
+ (its id and place in a sorted list are the server's), so `optimistic` /
197
+ `undoable` only change when it is sent and when the caller moves on. A
198
+ rollback restores only the row it patched, so concurrent writes on other
199
+ rows survive.
200
+ - **Refetching waits.** While an optimistic or undoable write on a resource is
201
+ pending, the hooks hold off invalidating `[resource]`; the last write to
202
+ settle refetches. Other code that invalidates the resource (or a window-focus
203
+ refetch) during the delay shows the server's rows until then.
204
+
205
+ ```ts
206
+ const tasks = useResource("tasks", {
207
+ mutationMode: "undoable",
208
+ onUndoable: (write) => toast("Deleted.", { action: { label: "Undo", onClick: () => void write.undo() } }),
209
+ onError: (error) => toast.error(error.message),
210
+ });
211
+ await tasks.remove(id); // resolves at once; sent in 5 s unless undone
212
+ ```
213
+
214
+ ## Unsaved changes
215
+
216
+ `useResourceForm({ warnWhenUnsaved: true })` (default off) warns while the
217
+ form is dirty: the browser's prompt on closing or reloading the tab, and
218
+ `confirmLeave()` for in-app navigation. It returns `isDirty` (false again once
219
+ a save succeeds or is handed off) and `confirmLeave()` (true when it is fine to
220
+ leave; asks with `window.confirm` otherwise). For other state,
221
+ `useUnsavedChangesGuard({ when, message, confirm })` gives the same
222
+ `confirmLeave` and `beforeunload` handling. Neither depends on a router; see
223
+ genesis-ui's SKILL.md "Unsaved changes" for React Router and TanStack Router.
@@ -5,6 +5,11 @@ var DataWriteError = class extends Error {
5
5
  currentVersion;
6
6
  /** The store's own error code, when it gave one. */
7
7
  code;
8
+ /**
9
+ * Per-field messages (field name → safe message), when the store said which
10
+ * values it rejected. See `DataWriteErrorOptions.fields`.
11
+ */
12
+ fields;
8
13
  constructor(outcome, message, options = {}) {
9
14
  super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
10
15
  this.name = "DataWriteError";
@@ -12,6 +17,9 @@ var DataWriteError = class extends Error {
12
17
  this.current = options.current;
13
18
  this.currentVersion = options.currentVersion;
14
19
  if (options.code !== void 0) this.code = options.code;
20
+ if (options.fields && Object.keys(options.fields).length > 0) {
21
+ this.fields = Object.freeze({ ...options.fields });
22
+ }
15
23
  }
16
24
  };
17
25
  function isDataWriteError(error) {
@@ -19,5 +27,5 @@ function isDataWriteError(error) {
19
27
  }
20
28
 
21
29
  export { DataWriteError, isDataWriteError };
22
- //# sourceMappingURL=chunk-6MBACQYA.js.map
23
- //# sourceMappingURL=chunk-6MBACQYA.js.map
30
+ //# sourceMappingURL=chunk-5NHR6ZXS.js.map
31
+ //# sourceMappingURL=chunk-5NHR6ZXS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/schema/write-outcome.ts"],"names":[],"mappings":";AAuDO,IAAM,cAAA,GAAN,cAA6B,KAAA,CAAM;AAAA,EAC/B,OAAA;AAAA,EACA,OAAA;AAAA,EACA,cAAA;AAAA;AAAA,EAEA,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAKA,MAAA;AAAA,EAET,WAAA,CAAY,OAAA,EAAuB,OAAA,EAAiB,OAAA,GAAiC,EAAC,EAAG;AACvF,IAAA,KAAA,CAAM,OAAA,EAAS,QAAQ,KAAA,KAAU,MAAA,GAAY,SAAY,EAAE,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAO,CAAA;AACjF,IAAA,IAAA,CAAK,IAAA,GAAO,gBAAA;AACZ,IAAA,IAAA,CAAK,OAAA,GAAU,OAAA;AACf,IAAA,IAAA,CAAK,UAAU,OAAA,CAAQ,OAAA;AACvB,IAAA,IAAA,CAAK,iBAAiB,OAAA,CAAQ,cAAA;AAC9B,IAAA,IAAI,OAAA,CAAQ,IAAA,KAAS,MAAA,EAAW,IAAA,CAAK,OAAO,OAAA,CAAQ,IAAA;AACpD,IAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,CAAO,IAAA,CAAK,QAAQ,MAAM,CAAA,CAAE,SAAS,CAAA,EAAG;AAC5D,MAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA,CAAO,EAAE,GAAG,OAAA,CAAQ,QAAQ,CAAA;AAAA,IACnD;AAAA,EACF;AACF;AAMO,SAAS,iBAAiB,KAAA,EAAyC;AACxE,EAAA,OACE,iBAAiB,KAAA,IACjB,KAAA,CAAM,SAAS,gBAAA,IACf,OAAQ,MAAgC,OAAA,KAAY,QAAA;AAExD","file":"chunk-5NHR6ZXS.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Why a write did not save (DATA-STUDIO.md \"Write safety\")\n//\n// A write that changes nothing is never reported as \"permission denied\" by\n// assumption. The provider re-reads the row and says what actually happened.\n// ---------------------------------------------------------------------------\n\nimport type { DataRow } from \"./types\";\n\n/**\n * - `conflict`: someone else changed the row since it was read. `current`\n * carries the row as it is now.\n * - `gone`: the row was deleted, or is no longer visible to this user. The\n * two cannot be told apart without a privileged look at the table, which\n * would show this user a fact about a row they cannot see.\n * - `forbidden`: the row is visible and unchanged, but the app or the\n * database does not let this user make this change. Only reported when\n * that is proven, never assumed from \"zero rows changed\".\n * - `invalid`: the store rejected the value (type, constraint, not-null).\n * - `audit_failed`: the audit entry could not be written, so the edit was\n * rolled back with it.\n * - `unauthenticated`: no signed-in user, or the session expired.\n * - `error`: anything else. The edit did not commit.\n */\nexport type WriteOutcome =\n | \"conflict\"\n | \"gone\"\n | \"forbidden\"\n | \"invalid\"\n | \"audit_failed\"\n | \"unauthenticated\"\n | \"error\";\n\nexport interface DataWriteErrorOptions {\n /** The row as it is now, for a `conflict`. Only fields the user may read. */\n current?: DataRow;\n /** The row's current version token, for a `conflict`. */\n currentVersion?: string | null;\n /**\n * The store's own error code, when it gave one (a Postgres SQLSTATE such as\n * `23505`, or a PostgREST code such as `PGRST301`).\n */\n code?: string;\n /**\n * Per-field messages, keyed by field (column) name, for a write the store\n * rejected because of particular values. Each message is safe to show next\n * to its input: plain words, never the store's raw text or the value.\n * `useResourceForm` puts each one on its field.\n */\n fields?: Record<string, string>;\n /** The underlying error, kept for logging. */\n cause?: unknown;\n}\n\n/** Thrown by a provider when a write did not commit. */\nexport class DataWriteError extends Error {\n readonly outcome: WriteOutcome;\n readonly current?: DataRow;\n readonly currentVersion?: string | null;\n /** The store's own error code, when it gave one. */\n readonly code?: string;\n /**\n * Per-field messages (field name → safe message), when the store said which\n * values it rejected. See `DataWriteErrorOptions.fields`.\n */\n readonly fields?: Readonly<Record<string, string>>;\n\n constructor(outcome: WriteOutcome, message: string, options: DataWriteErrorOptions = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.name = \"DataWriteError\";\n this.outcome = outcome;\n this.current = options.current;\n this.currentVersion = options.currentVersion;\n if (options.code !== undefined) this.code = options.code;\n if (options.fields && Object.keys(options.fields).length > 0) {\n this.fields = Object.freeze({ ...options.fields });\n }\n }\n}\n\n/**\n * `instanceof` fails when two copies of this package are installed, so check\n * the shape instead.\n */\nexport function isDataWriteError(error: unknown): error is DataWriteError {\n return (\n error instanceof Error &&\n error.name === \"DataWriteError\" &&\n typeof (error as { outcome?: unknown }).outcome === \"string\"\n );\n}\n"]}
@@ -0,0 +1,31 @@
1
+ import { useDataProvider } from './chunk-GGSXSI6Z.js';
2
+ import { useQuery } from '@tanstack/react-query';
3
+
4
+ var providerIds = /* @__PURE__ */ new WeakMap();
5
+ var nextProviderId = 1;
6
+ function providerId(provider) {
7
+ let id = providerIds.get(provider);
8
+ if (id === void 0) {
9
+ id = nextProviderId++;
10
+ providerIds.set(provider, id);
11
+ }
12
+ return id;
13
+ }
14
+ function capabilitiesQueryKey(provider, resource) {
15
+ return ["__capabilities", providerId(provider), resource ?? ""];
16
+ }
17
+ function useCapabilities(resource, options = {}) {
18
+ const provider = useDataProvider();
19
+ return useQuery({
20
+ queryKey: capabilitiesQueryKey(provider, resource),
21
+ queryFn: () => provider.capabilities(resource),
22
+ enabled: (options.enabled ?? true) && !!provider.capabilities,
23
+ // Fresh for as long as this provider is in use; a swapped-out provider's
24
+ // entry has no observers and is collected after the usual gcTime.
25
+ staleTime: Infinity
26
+ });
27
+ }
28
+
29
+ export { capabilitiesQueryKey, useCapabilities };
30
+ //# sourceMappingURL=chunk-BQOJX67X.js.map
31
+ //# sourceMappingURL=chunk-BQOJX67X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-capabilities.ts"],"names":[],"mappings":";;;AAMA,IAAM,WAAA,uBAAkB,OAAA,EAAwB;AAChD,IAAI,cAAA,GAAiB,CAAA;AAErB,SAAS,WAAW,QAAA,EAA0B;AAC5C,EAAA,IAAI,EAAA,GAAK,WAAA,CAAY,GAAA,CAAI,QAAQ,CAAA;AACjC,EAAA,IAAI,OAAO,MAAA,EAAW;AACpB,IAAA,EAAA,GAAK,cAAA,EAAA;AACL,IAAA,WAAA,CAAY,GAAA,CAAI,UAAU,EAAE,CAAA;AAAA,EAC9B;AACA,EAAA,OAAO,EAAA;AACT;AAQO,SAAS,oBAAA,CAAqB,UAAkB,QAAA,EAAkD;AACvG,EAAA,OAAO,CAAC,gBAAA,EAAkB,UAAA,CAAW,QAAQ,CAAA,EAAG,YAAY,EAAE,CAAA;AAChE;AAOO,SAAS,eAAA,CACd,QAAA,EACA,OAAA,GAAiC,EAAC,EACI;AACtC,EAAA,MAAM,WAAW,eAAA,EAAgB;AACjC,EAAA,OAAO,QAAA,CAA+B;AAAA,IACpC,QAAA,EAAU,oBAAA,CAAqB,QAAA,EAAU,QAAQ,CAAA;AAAA,IACjD,OAAA,EAAS,MAAM,QAAA,CAAS,YAAA,CAAc,QAAQ,CAAA;AAAA,IAC9C,UAAU,OAAA,CAAQ,OAAA,IAAW,IAAA,KAAS,CAAC,CAAC,QAAA,CAAS,YAAA;AAAA;AAAA;AAAA,IAGjD,SAAA,EAAW;AAAA,GACZ,CAAA;AACH","file":"chunk-BQOJX67X.js","sourcesContent":["import { useQuery, type UseQueryResult } from \"@tanstack/react-query\";\nimport { useDataProvider } from \"../provider/context\";\nimport type { ProviderCapabilities } from \"../schema/types\";\n\n// A stable id per provider object, so capabilities cached for one provider\n// are never served for another that shares the same QueryClient.\nconst providerIds = new WeakMap<object, number>();\nlet nextProviderId = 1;\n\nfunction providerId(provider: object): number {\n let id = providerIds.get(provider);\n if (id === undefined) {\n id = nextProviderId++;\n providerIds.set(provider, id);\n }\n return id;\n}\n\n/**\n * The query key for `provider`'s capabilities for `resource`. It includes\n * the provider's identity: swapping the provider (another tenant, a\n * different store) asks the new one instead of reusing the old answer.\n * @internal\n */\nexport function capabilitiesQueryKey(provider: object, resource: string | undefined): readonly unknown[] {\n return [\"__capabilities\", providerId(provider), resource ?? \"\"];\n}\n\n/**\n * What the data provider can do for `resource` (`provider.capabilities`),\n * cached per provider and resource. `data` stays undefined for a provider\n * that doesn't report capabilities, so a UI keeps its defaults.\n */\nexport function useCapabilities(\n resource: string | undefined,\n options: { enabled?: boolean } = {},\n): UseQueryResult<ProviderCapabilities> {\n const provider = useDataProvider();\n return useQuery<ProviderCapabilities>({\n queryKey: capabilitiesQueryKey(provider, resource),\n queryFn: () => provider.capabilities!(resource),\n enabled: (options.enabled ?? true) && !!provider.capabilities,\n // Fresh for as long as this provider is in use; a swapped-out provider's\n // entry has no observers and is collected after the usual gcTime.\n staleTime: Infinity,\n });\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { invalidateResource, useDelayedWrites, executeWrite, cachedRecord } from './chunk-QH4P5MI2.js';
1
2
  import { relatedKeys } from './chunk-7NUL32W7.js';
2
3
  import { useDataProvider } from './chunk-GGSXSI6Z.js';
3
4
  import { useState, useCallback, useMemo } from 'react';
@@ -20,6 +21,8 @@ function useResource(resource, options = {}) {
20
21
  defaultSort,
21
22
  defaultFilters,
22
23
  defaultPerPage = 25,
24
+ defaultPage = 1,
25
+ defaultSearch = "",
23
26
  select,
24
27
  searchFields: searchFieldsOption,
25
28
  enabled = true,
@@ -28,15 +31,18 @@ function useResource(resource, options = {}) {
28
31
  onCreateSuccess,
29
32
  onUpdateSuccess,
30
33
  onDeleteSuccess,
31
- onError
34
+ onError,
35
+ mutationMode = "pessimistic",
36
+ undoDelayMs,
37
+ onUndoable
32
38
  } = options;
33
39
  const provider = useDataProvider();
34
40
  const queryClient = useQueryClient();
35
41
  const [sort, setSortRaw] = useState(defaultSort);
36
42
  const [filters, setFiltersRaw] = useState(defaultFilters ?? []);
37
- const [page, setPage] = useState(1);
43
+ const [page, setPage] = useState(Math.max(1, Math.floor(defaultPage)));
38
44
  const [perPage, setPerPageRaw] = useState(defaultPerPage);
39
- const [searchQuery, setSearchQuery] = useState("");
45
+ const [searchQuery, setSearchQuery] = useState(defaultSearch);
40
46
  const defaultFiltersKey = stableFilterKey(defaultFilters);
41
47
  const [syncedFiltersKey, setSyncedFiltersKey] = useState(defaultFiltersKey);
42
48
  if (defaultFiltersKey !== syncedFiltersKey) {
@@ -84,7 +90,7 @@ function useResource(resource, options = {}) {
84
90
  });
85
91
  const invalidateAfter = useCallback(
86
92
  (event) => {
87
- void queryClient.invalidateQueries({ queryKey: [resource] });
93
+ void invalidateResource(queryClient, resource);
88
94
  for (const queryKey of relatedKeys(provider, event, invalidates)) {
89
95
  void queryClient.invalidateQueries({ queryKey: [...queryKey] });
90
96
  }
@@ -97,37 +103,75 @@ function useResource(resource, options = {}) {
97
103
  },
98
104
  [resource]
99
105
  );
106
+ const reportError = onError ?? defaultOnError;
107
+ const delayed = useDelayedWrites();
108
+ const modeArgs = {
109
+ queryClient,
110
+ provider,
111
+ resource,
112
+ mode: mutationMode,
113
+ undoDelayMs,
114
+ onUndoable,
115
+ delayed,
116
+ failedLater: reportError
117
+ };
100
118
  const createMutation = useMutation({
101
119
  mutationFn: async (data) => {
102
- const result = await provider.create(resource, { data });
103
- return result.data;
104
- },
105
- onSuccess: (data) => {
106
- invalidateAfter({ resource, kind: "create", id: data.id, record: data });
107
- onCreateSuccess?.(data);
120
+ const outcome = await executeWrite({
121
+ ...modeArgs,
122
+ kind: "create",
123
+ data,
124
+ send: async () => {
125
+ const result = await provider.create(resource, { data });
126
+ return { record: result.data, audit: result.audit };
127
+ },
128
+ committed: (record) => {
129
+ invalidateAfter({ resource, kind: "create", id: record.id, record });
130
+ onCreateSuccess?.(record);
131
+ }
132
+ });
133
+ return outcome.status === "committed" ? outcome.record : data;
108
134
  },
109
- onError: onError ?? defaultOnError
135
+ onError: reportError
110
136
  });
111
137
  const updateMutation = useMutation({
112
138
  mutationFn: async ({ id, data }) => {
113
- const result = await provider.update(resource, { id, data });
114
- return result.data;
139
+ const before = cachedRecord(queryClient, resource, id);
140
+ const outcome = await executeWrite({
141
+ ...modeArgs,
142
+ kind: "update",
143
+ id,
144
+ data,
145
+ send: async () => {
146
+ const result = await provider.update(resource, { id, data });
147
+ return { record: result.data, audit: result.audit };
148
+ },
149
+ committed: (record) => {
150
+ invalidateAfter({ resource, kind: "update", id, record });
151
+ onUpdateSuccess?.(record);
152
+ }
153
+ });
154
+ return outcome.status === "committed" ? outcome.record : { ...before ?? {}, ...data, id: before?.id ?? id };
115
155
  },
116
- onSuccess: (data, { id }) => {
117
- invalidateAfter({ resource, kind: "update", id, record: data });
118
- onUpdateSuccess?.(data);
119
- },
120
- onError: onError ?? defaultOnError
156
+ onError: reportError
121
157
  });
122
158
  const deleteMutation = useMutation({
123
159
  mutationFn: async (id) => {
124
- await provider.deleteOne(resource, { id });
125
- },
126
- onSuccess: (_data, id) => {
127
- invalidateAfter({ resource, kind: "delete", id });
128
- onDeleteSuccess?.();
160
+ await executeWrite({
161
+ ...modeArgs,
162
+ kind: "delete",
163
+ id,
164
+ send: async () => {
165
+ const result = await provider.deleteOne(resource, { id });
166
+ return { record: void 0, audit: result?.audit };
167
+ },
168
+ committed: () => {
169
+ invalidateAfter({ resource, kind: "delete", id });
170
+ onDeleteSuccess?.();
171
+ }
172
+ });
129
173
  },
130
- onError: onError ?? defaultOnError
174
+ onError: reportError
131
175
  });
132
176
  const create = useCallback(
133
177
  (data) => createMutation.mutateAsync(data),
@@ -161,5 +205,5 @@ function useResource(resource, options = {}) {
161
205
  }
162
206
 
163
207
  export { useResource };
164
- //# sourceMappingURL=chunk-P4OIIRQS.js.map
165
- //# sourceMappingURL=chunk-P4OIIRQS.js.map
208
+ //# sourceMappingURL=chunk-DVW5GXWZ.js.map
209
+ //# sourceMappingURL=chunk-DVW5GXWZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-resource.ts"],"names":["keepPreviousData","keepPreviousPlaceholder"],"mappings":"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","file":"chunk-DVW5GXWZ.js","sourcesContent":["import { useState, useCallback, useMemo } from \"react\";\nimport {\n keepPreviousData as keepPreviousPlaceholder,\n useQuery,\n useMutation,\n useQueryClient,\n type UseQueryResult,\n type UseMutationResult,\n} from \"@tanstack/react-query\";\nimport { useDataProvider } from \"../provider/context\";\nimport type {\n BaseRecord,\n Identifier,\n SortParam,\n FilterParam,\n ListParams,\n ListResult,\n InvalidationKey,\n WriteEvent,\n} from \"../provider/types\";\nimport { relatedKeys } from \"./related-keys\";\nimport {\n cachedRecord,\n executeWrite,\n invalidateResource,\n useDelayedWrites,\n type MutationModeOptions,\n} from \"./mutation-mode\";\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport interface UseResourceOptions<T> extends MutationModeOptions {\n defaultSort?: SortParam;\n /**\n * Re-synced whenever its value changes (deep-equal, order-independent),\n * not just its identity — safe to pass a scope/audience-derived array that\n * changes across renders. This only covers CHANGES on an already-mounted\n * hook, though: if this starts `undefined` while the real value resolves\n * asynchronously, gate `enabled` on that same readiness signal too — the\n * hook cannot tell \"no defaultFilters intended\" apart from \"not resolved\n * yet,\" so an ungated hook's very first fetch can still run unscoped.\n */\n defaultFilters?: FilterParam[];\n defaultPerPage?: number;\n /** The page to start on (from 1), e.g. restored from the URL. Default 1. */\n defaultPage?: number;\n /** The search to start with, e.g. restored from the URL. Default \"\". */\n defaultSearch?: string;\n /** Supabase select/join syntax, e.g. \"*, author:profiles(name)\". */\n select?: string;\n /** Fields to search across when setSearch is called. Required for server-side search. */\n searchFields?: string[];\n /** Disable auto-fetching. */\n enabled?: boolean;\n /**\n * Other query keys to invalidate after a create, update or delete, on top\n * of `[resource]` and the provider's own `invalidates` (e.g. `[[\"me\"]]`\n * when a write changes the signed-in user's roles).\n */\n invalidates?: InvalidationKey[];\n /**\n * While the next page / sort / search is loading, keep returning the\n * previous result (`list.isPlaceholderData` is true meanwhile) instead of\n * dropping to `undefined`. A paged list then stays on screen between pages\n * rather than flashing a skeleton. Default false.\n */\n keepPreviousData?: boolean;\n /**\n * After the write commits, with the provider's row. In `optimistic` and\n * `undoable` modes that is after `create` / `update` / `remove` have\n * already resolved.\n */\n onCreateSuccess?: (data: T) => void;\n onUpdateSuccess?: (data: T) => void;\n onDeleteSuccess?: () => void;\n /**\n * A write failed. In `optimistic` and `undoable` modes this is the only\n * place a failure is reported (the cache has already been put back), so\n * show it to the user here.\n */\n onError?: (error: Error) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Return type\n// ---------------------------------------------------------------------------\n\nexport interface UseResourceReturn<T> {\n /** TanStack Query result for the list query. */\n list: UseQueryResult<ListResult<T>>;\n /** Current list params (pagination, sort, filters, search). */\n params: ListParams;\n setSort: (sort: SortParam | undefined) => void;\n setFilters: (filters: FilterParam[]) => void;\n setPage: (page: number) => void;\n setPerPage: (perPage: number) => void;\n setSearch: (query: string) => void;\n /**\n * Create a new record. Returns the created item. In `optimistic` and\n * `undoable` modes it resolves at once with the values given (no `id` yet;\n * `onCreateSuccess` gets the stored row).\n */\n create: (data: Partial<T>) => Promise<T>;\n /**\n * Update an existing record by id. Returns the updated item. In\n * `optimistic` and `undoable` modes it resolves at once with the cached\n * row merged with `data`.\n */\n update: (id: Identifier, data: Partial<T>) => Promise<T>;\n /** Delete a record by id. Resolves at once in `optimistic` / `undoable` modes. */\n remove: (id: Identifier) => Promise<void>;\n /** True when any mutation is in-flight. */\n isMutating: boolean;\n createMutation: UseMutationResult<T, Error, Partial<T>>;\n updateMutation: UseMutationResult<T, Error, { id: Identifier; data: Partial<T> }>;\n deleteMutation: UseMutationResult<void, Error, Identifier>;\n}\n\n// ---------------------------------------------------------------------------\n// Filter identity — a deep-equality key independent of object-key order, so\n// a caller that rebuilds the same logical filters via a differently-ordered\n// spread (e.g. `{...base, operator: \"eq\"}` vs `{operator: \"eq\", ...base}`)\n// doesn't trip a spurious resync + pagination reset on an unrelated rerender.\n// ---------------------------------------------------------------------------\n\nfunction stableFilterKey(filters: FilterParam[] | undefined): string | undefined {\n if (!filters) return undefined;\n return JSON.stringify(\n filters.map((f) => {\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(f).sort()) {\n sorted[key] = (f as unknown as Record<string, unknown>)[key];\n }\n return sorted;\n }),\n );\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useResource<T extends BaseRecord = BaseRecord>(\n resource: string,\n options: UseResourceOptions<T> = {},\n): UseResourceReturn<T> {\n const {\n defaultSort,\n defaultFilters,\n defaultPerPage = 25,\n defaultPage = 1,\n defaultSearch = \"\",\n select,\n searchFields: searchFieldsOption,\n enabled = true,\n invalidates,\n keepPreviousData = false,\n onCreateSuccess,\n onUpdateSuccess,\n onDeleteSuccess,\n onError,\n mutationMode = \"pessimistic\",\n undoDelayMs,\n onUndoable,\n } = options;\n\n const provider = useDataProvider();\n const queryClient = useQueryClient();\n\n // -------------------------------------------------------------------------\n // State\n // -------------------------------------------------------------------------\n\n const [sort, setSortRaw] = useState<SortParam | undefined>(defaultSort);\n const [filters, setFiltersRaw] = useState<FilterParam[]>(defaultFilters ?? []);\n const [page, setPage] = useState(Math.max(1, Math.floor(defaultPage)));\n const [perPage, setPerPageRaw] = useState(defaultPerPage);\n const [searchQuery, setSearchQuery] = useState(defaultSearch);\n\n // `defaultFilters` often resolves asynchronously — a caller's scope/audience\n // (team, project, ...) can still be loading on first render, so the array\n // it passes in changes value (not just identity) after mount, and may later\n // clear back to undefined (e.g. the scope itself is cleared). Re-syncing\n // here, during render rather than in an effect, means `filters` is already\n // correct by the time `params`/`list` below compute for this pass — no\n // stray unscoped fetch on the frame before an effect would have caught up.\n //\n // This only closes the gap for `defaultFilters` CHANGING across renders on\n // an already-mounted, already-enabled hook (e.g. a scope switch). It cannot\n // help the very first render: `filters` still seeds from whatever\n // `defaultFilters` is on mount, and `undefined` here is genuinely ambiguous\n // between \"this caller never uses defaultFilters\" (in which case forcing a\n // fetch to wait would break it forever) and \"the real value hasn't resolved\n // yet.\" A caller in the second case MUST also gate `enabled` on that same\n // readiness signal for its very first fetch to be correctly scoped — this\n // hook has no way to tell the two cases apart on its own.\n const defaultFiltersKey = stableFilterKey(defaultFilters);\n const [syncedFiltersKey, setSyncedFiltersKey] = useState(defaultFiltersKey);\n if (defaultFiltersKey !== syncedFiltersKey) {\n setSyncedFiltersKey(defaultFiltersKey);\n setFiltersRaw(defaultFilters ?? []);\n setPage(1);\n }\n\n const setSort = useCallback((s: SortParam | undefined) => {\n setSortRaw(s);\n setPage(1);\n }, []);\n\n const setFilters = useCallback((f: FilterParam[]) => {\n setFiltersRaw(f);\n setPage(1);\n }, []);\n\n const setPerPage = useCallback((value: number) => {\n setPerPageRaw(value);\n setPage(1);\n }, []);\n\n const setSearch = useCallback((query: string) => {\n setSearchQuery(query);\n setPage(1);\n }, []);\n\n // -------------------------------------------------------------------------\n // Params\n // -------------------------------------------------------------------------\n\n const params = useMemo<ListParams>(() => {\n const p: ListParams = {\n pagination: { page, perPage },\n sort,\n filters: filters.length > 0 ? filters : undefined,\n select,\n };\n if (searchQuery && searchFieldsOption && searchFieldsOption.length > 0) {\n p.search = { query: searchQuery, fields: searchFieldsOption };\n }\n return p;\n }, [page, perPage, sort, filters, select, searchQuery, searchFieldsOption]);\n\n // -------------------------------------------------------------------------\n // List query\n // -------------------------------------------------------------------------\n\n const listQueryKey = useMemo(\n () => [resource, \"list\", params] as const,\n [resource, params],\n );\n\n const list = useQuery<ListResult<T>>({\n queryKey: listQueryKey,\n queryFn: () => provider.getList<T>(resource, params),\n enabled,\n placeholderData: keepPreviousData ? keepPreviousPlaceholder : undefined,\n });\n\n // -------------------------------------------------------------------------\n // Mutations\n // -------------------------------------------------------------------------\n\n const invalidateAfter = useCallback(\n (event: WriteEvent) => {\n // Waits while an optimistic/undoable write on the resource is pending.\n void invalidateResource(queryClient, resource);\n for (const queryKey of relatedKeys(provider, event, invalidates)) {\n void queryClient.invalidateQueries({ queryKey: [...queryKey] });\n }\n },\n [queryClient, resource, provider, invalidates],\n );\n\n const defaultOnError = useCallback(\n (error: Error) => {\n console.error(`[useResource] ${resource} mutation failed:`, error.message);\n },\n [resource],\n );\n const reportError = onError ?? defaultOnError;\n\n const delayed = useDelayedWrites();\n const modeArgs = {\n queryClient,\n provider,\n resource,\n mode: mutationMode,\n undoDelayMs,\n onUndoable,\n delayed,\n failedLater: reportError,\n };\n\n const createMutation = useMutation<T, Error, Partial<T>>({\n mutationFn: async (data) => {\n const outcome = await executeWrite<T>({\n ...modeArgs,\n kind: \"create\",\n data: data as Record<string, unknown>,\n send: async () => {\n const result = await provider.create<T>(resource, { data });\n return { record: result.data, audit: result.audit };\n },\n committed: (record) => {\n invalidateAfter({ resource, kind: \"create\", id: record.id, record });\n onCreateSuccess?.(record);\n },\n });\n return outcome.status === \"committed\" ? outcome.record : (data as T);\n },\n onError: reportError,\n });\n\n const updateMutation = useMutation<\n T,\n Error,\n { id: Identifier; data: Partial<T> }\n >({\n mutationFn: async ({ id, data }) => {\n const before = cachedRecord(queryClient, resource, id);\n const outcome = await executeWrite<T>({\n ...modeArgs,\n kind: \"update\",\n id,\n data: data as Record<string, unknown>,\n send: async () => {\n const result = await provider.update<T>(resource, { id, data });\n return { record: result.data, audit: result.audit };\n },\n committed: (record) => {\n invalidateAfter({ resource, kind: \"update\", id, record });\n onUpdateSuccess?.(record);\n },\n });\n return outcome.status === \"committed\"\n ? outcome.record\n : ({ ...(before ?? {}), ...data, id: before?.id ?? id } as T);\n },\n onError: reportError,\n });\n\n const deleteMutation = useMutation<void, Error, Identifier>({\n mutationFn: async (id) => {\n await executeWrite<void>({\n ...modeArgs,\n kind: \"delete\",\n id,\n send: async () => {\n const result = await provider.deleteOne(resource, { id });\n return { record: undefined, audit: result?.audit };\n },\n committed: () => {\n invalidateAfter({ resource, kind: \"delete\", id });\n onDeleteSuccess?.();\n },\n });\n },\n onError: reportError,\n });\n\n // -------------------------------------------------------------------------\n // Convenience wrappers\n // -------------------------------------------------------------------------\n\n const create = useCallback(\n (data: Partial<T>) => createMutation.mutateAsync(data),\n [createMutation],\n );\n\n const update = useCallback(\n (id: Identifier, data: Partial<T>) =>\n updateMutation.mutateAsync({ id, data }),\n [updateMutation],\n );\n\n const remove = useCallback(\n (id: Identifier) => deleteMutation.mutateAsync(id),\n [deleteMutation],\n );\n\n const isMutating =\n createMutation.isPending ||\n updateMutation.isPending ||\n deleteMutation.isPending;\n\n return {\n list,\n params,\n setSort,\n setFilters,\n setPage,\n setPerPage,\n setSearch,\n create,\n update,\n remove,\n isMutating,\n createMutation,\n updateMutation,\n deleteMutation,\n };\n}\n"]}
@@ -0,0 +1,3 @@
1
+
2
+ //# sourceMappingURL=chunk-HT43MPKW.js.map
3
+ //# sourceMappingURL=chunk-HT43MPKW.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-EL7YGOTY.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-HT43MPKW.js"}
@@ -0,0 +1,33 @@
1
+ // src/schema/read-outcome.ts
2
+ var DataReadError = class extends Error {
3
+ outcome;
4
+ /** The store's own error code, when it gave one. */
5
+ code;
6
+ constructor(outcome, message, options = {}) {
7
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
8
+ this.name = "DataReadError";
9
+ this.outcome = outcome;
10
+ if (options.code !== void 0) this.code = options.code;
11
+ }
12
+ };
13
+ function isDataReadError(error) {
14
+ return error instanceof Error && error.name === "DataReadError" && typeof error.outcome === "string";
15
+ }
16
+ function readFailureMessage(outcome, noun, one, plural = `${noun}s`) {
17
+ switch (outcome) {
18
+ case "unauthenticated":
19
+ return "Your session has ended. Sign in again to continue.";
20
+ case "forbidden":
21
+ return one ? `You don't have access to this ${noun}.` : `You don't have access to these ${plural}.`;
22
+ case "gone":
23
+ return `This ${noun} doesn't exist, or you can't see it.`;
24
+ case "invalid":
25
+ return one ? `This ${noun} couldn't be loaded.` : `These ${plural} couldn't be loaded.`;
26
+ default:
27
+ return one ? `This ${noun} couldn't be loaded. Try again.` : `These ${plural} couldn't be loaded. Try again.`;
28
+ }
29
+ }
30
+
31
+ export { DataReadError, isDataReadError, readFailureMessage };
32
+ //# sourceMappingURL=chunk-HUNGCTRA.js.map
33
+ //# sourceMappingURL=chunk-HUNGCTRA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/schema/read-outcome.ts"],"names":[],"mappings":";AAyBO,IAAM,aAAA,GAAN,cAA4B,KAAA,CAAM;AAAA,EAC9B,OAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,OAAA,EAAsB,OAAA,EAAiB,OAAA,GAAgC,EAAC,EAAG;AACrF,IAAA,KAAA,CAAM,OAAA,EAAS,QAAQ,KAAA,KAAU,MAAA,GAAY,SAAY,EAAE,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAO,CAAA;AACjF,IAAA,IAAA,CAAK,IAAA,GAAO,eAAA;AACZ,IAAA,IAAA,CAAK,OAAA,GAAU,OAAA;AACf,IAAA,IAAI,OAAA,CAAQ,IAAA,KAAS,MAAA,EAAW,IAAA,CAAK,OAAO,OAAA,CAAQ,IAAA;AAAA,EACtD;AACF;AAMO,SAAS,gBAAgB,KAAA,EAAwC;AACtE,EAAA,OACE,iBAAiB,KAAA,IACjB,KAAA,CAAM,SAAS,eAAA,IACf,OAAQ,MAAgC,OAAA,KAAY,QAAA;AAExD;AAGO,SAAS,mBAAmB,OAAA,EAAsB,IAAA,EAAc,KAAc,MAAA,GAAS,CAAA,EAAG,IAAI,CAAA,CAAA,CAAA,EAAa;AAChH,EAAA,QAAQ,OAAA;AAAS,IACf,KAAK,iBAAA;AACH,MAAA,OAAO,oDAAA;AAAA,IACT,KAAK,WAAA;AACH,MAAA,OAAO,GAAA,GAAM,CAAA,8BAAA,EAAiC,IAAI,CAAA,CAAA,CAAA,GAAM,kCAAkC,MAAM,CAAA,CAAA,CAAA;AAAA,IAClG,KAAK,MAAA;AACH,MAAA,OAAO,QAAQ,IAAI,CAAA,oCAAA,CAAA;AAAA,IACrB,KAAK,SAAA;AACH,MAAA,OAAO,GAAA,GAAM,CAAA,KAAA,EAAQ,IAAI,CAAA,oBAAA,CAAA,GAAyB,SAAS,MAAM,CAAA,oBAAA,CAAA;AAAA,IACnE;AACE,MAAA,OAAO,GAAA,GAAM,CAAA,KAAA,EAAQ,IAAI,CAAA,+BAAA,CAAA,GAAoC,SAAS,MAAM,CAAA,+BAAA,CAAA;AAAA;AAElF","file":"chunk-HUNGCTRA.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Why a read failed, in words a user can be shown\n//\n// A store's own error text (\"relation \\\"tasks\\\" does not exist\", \"JWT\n// expired\", a constraint name) is for developers. A read error carries a\n// plain message for the screen and keeps the store's error as its `cause`.\n// ---------------------------------------------------------------------------\n\n/**\n * - `unauthenticated`: no signed-in user, or the session expired.\n * - `forbidden`: this user may not read these records.\n * - `gone`: the one record asked for doesn't exist, or isn't visible.\n * - `invalid`: the request itself was wrong (an unknown column, a bad value).\n * - `error`: anything else.\n */\nexport type ReadOutcome = \"unauthenticated\" | \"forbidden\" | \"gone\" | \"invalid\" | \"error\";\n\nexport interface DataReadErrorOptions {\n /** The store's own error code (a Postgres SQLSTATE or PostgREST code). */\n code?: string;\n /** The underlying error, with its raw text, kept for logging. */\n cause?: unknown;\n}\n\n/** Thrown by a provider when a read failed. Its `message` is safe to show. */\nexport class DataReadError extends Error {\n readonly outcome: ReadOutcome;\n /** The store's own error code, when it gave one. */\n readonly code?: string;\n\n constructor(outcome: ReadOutcome, message: string, options: DataReadErrorOptions = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.name = \"DataReadError\";\n this.outcome = outcome;\n if (options.code !== undefined) this.code = options.code;\n }\n}\n\n/**\n * `instanceof` fails when two copies of this package are installed, so check\n * the shape instead.\n */\nexport function isDataReadError(error: unknown): error is DataReadError {\n return (\n error instanceof Error &&\n error.name === \"DataReadError\" &&\n typeof (error as { outcome?: unknown }).outcome === \"string\"\n );\n}\n\n/** The plain sentence for a failed read of `noun` records (one, or a list). */\nexport function readFailureMessage(outcome: ReadOutcome, noun: string, one: boolean, plural = `${noun}s`): string {\n switch (outcome) {\n case \"unauthenticated\":\n return \"Your session has ended. Sign in again to continue.\";\n case \"forbidden\":\n return one ? `You don't have access to this ${noun}.` : `You don't have access to these ${plural}.`;\n case \"gone\":\n return `This ${noun} doesn't exist, or you can't see it.`;\n case \"invalid\":\n return one ? `This ${noun} couldn't be loaded.` : `These ${plural} couldn't be loaded.`;\n default:\n return one ? `This ${noun} couldn't be loaded. Try again.` : `These ${plural} couldn't be loaded. Try again.`;\n }\n}\n"]}
@@ -1,8 +1,11 @@
1
+ import { useUnsavedChangesGuard } from './chunk-XQO4OH2N.js';
2
+ import { isDataWriteError } from './chunk-5NHR6ZXS.js';
1
3
  import { titleCase } from './chunk-RYEXWQFD.js';
2
- import { useOne } from './chunk-ZK5C3OOT.js';
4
+ import { useDelayedWrites, cachedRecord, executeWrite, invalidateResource } from './chunk-QH4P5MI2.js';
3
5
  import { relatedKeys } from './chunk-7NUL32W7.js';
6
+ import { useOne } from './chunk-ZK5C3OOT.js';
4
7
  import { useDataProvider, useStorage } from './chunk-GGSXSI6Z.js';
5
- import { useMemo, useEffect, useCallback } from 'react';
8
+ import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
6
9
  import { useForm } from 'react-hook-form';
7
10
  import { zodResolver } from '@hookform/resolvers/zod';
8
11
  import { z } from 'zod';
@@ -234,7 +237,11 @@ function useResourceForm(options) {
234
237
  autoPlaceholder = true,
235
238
  validationMode,
236
239
  autosave,
237
- invalidates
240
+ invalidates,
241
+ warnWhenUnsaved = false,
242
+ mutationMode = "pessimistic",
243
+ undoDelayMs,
244
+ onUndoable
238
245
  } = options;
239
246
  const autosaveEnabled = !!autosave;
240
247
  const debounceMs = typeof autosave === "object" ? autosave.debounceMs ?? 1e3 : 1e3;
@@ -320,54 +327,133 @@ function useResourceForm(options) {
320
327
  subscription.unsubscribe();
321
328
  };
322
329
  }, [autosaveEnabled, resource, action, form, debounceMs, storage]);
330
+ const rhfDirty = form.formState.isDirty;
331
+ const [savedClean, setSavedClean] = useState(false);
332
+ const savedCleanRef = useRef(false);
333
+ useEffect(() => {
334
+ const subscription = form.watch((_values, info) => {
335
+ if (!info.name) return;
336
+ savedCleanRef.current = false;
337
+ setSavedClean(false);
338
+ });
339
+ return () => subscription.unsubscribe();
340
+ }, [form]);
341
+ const isDirty = rhfDirty && !savedClean;
342
+ const warn = !!warnWhenUnsaved;
343
+ const { confirmLeave: guardConfirm } = useUnsavedChangesGuard({
344
+ when: warn && isDirty,
345
+ message: typeof warnWhenUnsaved === "object" ? warnWhenUnsaved.message : void 0
346
+ });
347
+ const confirmLeave = useCallback(
348
+ // The ref, not state: a router blocker may ask in the same tick as the
349
+ // save's onSuccess navigates, before a re-render.
350
+ () => !warn || savedCleanRef.current ? true : guardConfirm(),
351
+ [warn, guardConfirm]
352
+ );
353
+ const markSaved = useCallback(() => {
354
+ savedCleanRef.current = true;
355
+ setSavedClean(true);
356
+ }, []);
357
+ const delayed = useDelayedWrites();
358
+ const showError = useCallback(
359
+ (error) => {
360
+ const fieldErrors = isDataWriteError(error) ? error.fields : void 0;
361
+ const known = new Set(resolvedFields.map((f) => f.key));
362
+ let first;
363
+ if (fieldErrors) {
364
+ for (const [field, message] of Object.entries(fieldErrors)) {
365
+ if (!known.has(field)) continue;
366
+ form.setError(field, { type: "server", message });
367
+ first ??= field;
368
+ }
369
+ }
370
+ form.setError("root", { type: "server", message: error.message });
371
+ if (first) {
372
+ try {
373
+ form.setFocus(first);
374
+ } catch {
375
+ }
376
+ }
377
+ },
378
+ [form, resolvedFields]
379
+ );
380
+ const reportError = useCallback(
381
+ (error) => {
382
+ savedCleanRef.current = false;
383
+ setSavedClean(false);
384
+ showError(error);
385
+ if (onError) {
386
+ onError(error);
387
+ } else {
388
+ console.error(`[useResourceForm] ${action} failed for "${resource}":`, error);
389
+ }
390
+ },
391
+ [showError, onError, action, resource]
392
+ );
323
393
  const handleSubmit = useCallback(
324
394
  async (formData) => {
325
395
  try {
326
- let result;
327
396
  let recordId;
328
- if (action === "create") {
329
- const res = await provider.create(resource, { data: formData });
330
- result = res.data;
331
- } else {
397
+ if (action === "edit") {
332
398
  recordId = id ?? String(effectiveData?.id ?? "");
333
399
  if (!recordId) {
334
400
  throw new Error("Cannot update record: no ID available. Provide an id prop or ensure the record has an id field.");
335
401
  }
336
- const res = await provider.update(resource, { id: recordId, data: formData });
337
- result = res.data;
338
- }
339
- if (autosaveEnabled && action === "create") {
340
- try {
341
- storage.removeItem(`genesis-draft:${resource}:new`);
342
- } catch (err) {
343
- console.warn(`[useResourceForm] Draft cleanup failed for "${resource}":`, err instanceof Error ? err.message : String(err));
344
- }
345
402
  }
346
- await queryClient.invalidateQueries({ queryKey: [resource] });
347
- const related = relatedKeys(
403
+ const before = recordId != null ? cachedRecord(queryClient, resource, recordId) : void 0;
404
+ const outcome = await executeWrite({
405
+ queryClient,
348
406
  provider,
349
- {
350
- resource,
351
- kind: action === "create" ? "create" : "update",
352
- id: recordId ?? result.id,
353
- record: result
407
+ resource,
408
+ kind: action === "create" ? "create" : "update",
409
+ id: recordId,
410
+ // The cache holds rows; patch it only when the form's values are
411
+ // row-shaped (no provider.toFormValues reshaping this record).
412
+ data: formSource === effectiveData ? formData : void 0,
413
+ mode: mutationMode,
414
+ undoDelayMs,
415
+ onUndoable,
416
+ delayed,
417
+ send: async () => {
418
+ const res = action === "create" ? await provider.create(resource, { data: formData }) : await provider.update(resource, { id: recordId, data: formData });
419
+ return { record: res.data, audit: res.audit };
354
420
  },
355
- invalidates
356
- );
357
- await Promise.all(related.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })));
358
- onSuccess?.(result);
359
- } catch (err) {
360
- const error = err instanceof Error ? err : new Error(String(err));
361
- form.setError("root", { message: error.message });
362
- if (onError) {
363
- onError(error);
421
+ committed: async (result) => {
422
+ if (autosaveEnabled && action === "create") {
423
+ try {
424
+ storage.removeItem(`genesis-draft:${resource}:new`);
425
+ } catch (err) {
426
+ console.warn(`[useResourceForm] Draft cleanup failed for "${resource}":`, err instanceof Error ? err.message : String(err));
427
+ }
428
+ }
429
+ await invalidateResource(queryClient, resource);
430
+ const related = relatedKeys(
431
+ provider,
432
+ {
433
+ resource,
434
+ kind: action === "create" ? "create" : "update",
435
+ id: recordId ?? result.id,
436
+ record: result
437
+ },
438
+ invalidates
439
+ );
440
+ await Promise.all(related.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })));
441
+ },
442
+ failedLater: reportError
443
+ });
444
+ markSaved();
445
+ if (outcome.status === "committed") {
446
+ onSuccess?.(outcome.record);
364
447
  } else {
365
- console.error(`[useResourceForm] ${action} failed for "${resource}":`, error);
448
+ onSuccess?.(
449
+ action === "edit" ? { ...before ?? effectiveData ?? {}, ...formData, id: recordId } : { ...formData }
450
+ );
366
451
  }
452
+ } catch (err) {
453
+ reportError(err instanceof Error ? err : new Error(String(err)));
367
454
  }
368
455
  },
369
- // form is stable (from useForm) but included for correctness since we call form.setError
370
- [action, provider, resource, id, effectiveData, queryClient, onSuccess, onError, autosaveEnabled, form, storage, invalidates]
456
+ [action, provider, resource, id, effectiveData, formSource, queryClient, onSuccess, autosaveEnabled, storage, invalidates, mutationMode, undoDelayMs, onUndoable, delayed, reportError, markSaved]
371
457
  );
372
458
  const onSubmit = useCallback(
373
459
  (e) => form.handleSubmit(handleSubmit)(e),
@@ -380,10 +466,12 @@ function useResourceForm(options) {
380
466
  onSubmit,
381
467
  handleSave: handleSubmit,
382
468
  isSaving,
383
- isLoading
469
+ isLoading,
470
+ isDirty,
471
+ confirmLeave
384
472
  };
385
473
  }
386
474
 
387
475
  export { buildArrayItemDefaults, useResourceForm };
388
- //# sourceMappingURL=chunk-IHWWJUEN.js.map
389
- //# sourceMappingURL=chunk-IHWWJUEN.js.map
476
+ //# sourceMappingURL=chunk-IZT2O57C.js.map
477
+ //# sourceMappingURL=chunk-IZT2O57C.js.map