@chelone/core 0.6.0 → 0.9.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 (255) hide show
  1. package/dist/assistant/assistant-session.d.ts +6 -0
  2. package/dist/assistant/assistant-session.js +204 -181
  3. package/dist/assistant/echo-transport.js +26 -29
  4. package/dist/assistant/http-transport.d.ts +10 -0
  5. package/dist/assistant/http-transport.js +98 -0
  6. package/dist/assistant/types.d.ts +1 -7
  7. package/dist/auth/access-control.d.ts +13 -5
  8. package/dist/auth/access-control.js +25 -21
  9. package/dist/auth/auth-options.d.ts +23 -17
  10. package/dist/auth/auth-options.js +82 -48
  11. package/dist/auth/permission-matrix.d.ts +12 -0
  12. package/dist/auth/permission-matrix.js +67 -0
  13. package/dist/auth/types.d.ts +14 -4
  14. package/dist/block-editor/block-document.d.ts +64 -0
  15. package/dist/block-editor/block-document.js +147 -0
  16. package/dist/block-editor/data-blocks.d.ts +63 -0
  17. package/dist/block-editor/data-blocks.js +117 -0
  18. package/dist/chart/chart-data.js +46 -44
  19. package/dist/command-menu/hotkey.js +58 -47
  20. package/dist/command-menu/recents.d.ts +4 -0
  21. package/dist/command-menu/recents.js +12 -11
  22. package/dist/command-menu/registry.d.ts +9 -5
  23. package/dist/command-menu/registry.js +36 -46
  24. package/dist/controller/calendar-dates.d.ts +12 -0
  25. package/dist/controller/calendar-dates.js +94 -113
  26. package/dist/controller/calendar-events.js +93 -88
  27. package/dist/controller/client-list-controller.d.ts +2 -2
  28. package/dist/controller/client-list-controller.js +251 -254
  29. package/dist/controller/data-table-columns.d.ts +2 -0
  30. package/dist/controller/data-table-columns.js +50 -56
  31. package/dist/controller/deep-equal.d.ts +1 -0
  32. package/dist/controller/deep-equal.js +14 -0
  33. package/dist/controller/filter-toggle.js +36 -44
  34. package/dist/controller/in-place-editor.d.ts +43 -0
  35. package/dist/controller/in-place-editor.js +43 -0
  36. package/dist/controller/infinite-list-controller.js +219 -189
  37. package/dist/controller/inspector.d.ts +55 -0
  38. package/dist/controller/inspector.js +70 -0
  39. package/dist/controller/job-monitor.d.ts +10 -4
  40. package/dist/controller/job-monitor.js +46 -47
  41. package/dist/controller/kanban-board.js +45 -24
  42. package/dist/controller/list-controller.d.ts +8 -5
  43. package/dist/controller/list-controller.js +447 -400
  44. package/dist/controller/list-params.js +96 -87
  45. package/dist/controller/pivot-grid.d.ts +4 -4
  46. package/dist/controller/pivot-grid.js +89 -95
  47. package/dist/controller/prev-next.d.ts +2 -2
  48. package/dist/controller/prev-next.js +74 -54
  49. package/dist/controller/record-controllers.d.ts +14 -6
  50. package/dist/controller/record-controllers.js +288 -257
  51. package/dist/controller/record-diff.d.ts +21 -0
  52. package/dist/controller/record-diff.js +50 -0
  53. package/dist/controller/record-timeline.d.ts +5 -5
  54. package/dist/controller/record-timeline.js +42 -30
  55. package/dist/controller/record-tree.js +57 -47
  56. package/dist/controller/record-workflow.d.ts +38 -16
  57. package/dist/controller/record-workflow.js +85 -64
  58. package/dist/controller/reference-many-controller.d.ts +2 -0
  59. package/dist/controller/reference-many-controller.js +199 -192
  60. package/dist/controller/reference-one-controller.d.ts +1 -1
  61. package/dist/controller/reference-one-controller.js +63 -56
  62. package/dist/controller/saved-queries.js +104 -91
  63. package/dist/controller/translate-fallback.js +10 -6
  64. package/dist/csv/export.d.ts +4 -3
  65. package/dist/csv/export.js +43 -40
  66. package/dist/csv/import-controller.d.ts +4 -0
  67. package/dist/csv/import-controller.js +189 -169
  68. package/dist/csv/match-headers.js +29 -22
  69. package/dist/csv/parse-with.js +60 -54
  70. package/dist/csv/run-import.js +69 -72
  71. package/dist/csv/validate-rows.d.ts +1 -1
  72. package/dist/csv/validate-rows.js +65 -52
  73. package/dist/data/cache-ops.d.ts +5 -4
  74. package/dist/data/cache-ops.js +208 -188
  75. package/dist/data/combine-data-providers.js +25 -23
  76. package/dist/data/data-provider-log.d.ts +45 -0
  77. package/dist/data/data-provider-log.js +93 -0
  78. package/dist/data/errors.js +14 -10
  79. package/dist/data/fake-provider.d.ts +6 -3
  80. package/dist/data/fake-provider.js +135 -114
  81. package/dist/data/fetch-utils.js +61 -44
  82. package/dist/data/filter-branch.d.ts +5 -0
  83. package/dist/data/filter-branch.js +10 -0
  84. package/dist/data/get-many-aggregate.js +84 -76
  85. package/dist/data/http-error.js +17 -12
  86. package/dist/data/keys.js +50 -36
  87. package/dist/data/mutation-options.d.ts +14 -9
  88. package/dist/data/mutation-options.js +93 -109
  89. package/dist/data/query-options.js +73 -68
  90. package/dist/data/remove-empty.d.ts +3 -0
  91. package/dist/data/remove-empty.js +11 -0
  92. package/dist/data/undoable.d.ts +2 -2
  93. package/dist/data/undoable.js +190 -174
  94. package/dist/data/with-lifecycle-callbacks.d.ts +45 -17
  95. package/dist/data/with-lifecycle-callbacks.js +66 -52
  96. package/dist/environment/application-update-store.js +58 -55
  97. package/dist/environment/offline-store.js +21 -21
  98. package/dist/form/array-binding.d.ts +30 -11
  99. package/dist/form/array-binding.js +184 -166
  100. package/dist/form/choice-ids.d.ts +7 -0
  101. package/dist/form/choice-ids.js +11 -0
  102. package/dist/form/choices-controller.d.ts +22 -2
  103. package/dist/form/choices-controller.js +315 -275
  104. package/dist/form/date-format.d.ts +38 -0
  105. package/dist/form/date-format.js +89 -0
  106. package/dist/form/dirty-values.d.ts +2 -2
  107. package/dist/form/dirty-values.js +22 -11
  108. package/dist/form/duration.d.ts +22 -0
  109. package/dist/form/duration.js +49 -0
  110. package/dist/form/edit-create-form.d.ts +12 -5
  111. package/dist/form/edit-create-form.js +243 -188
  112. package/dist/form/event-value.d.ts +6 -0
  113. package/dist/form/event-value.js +17 -0
  114. package/dist/form/field-error.d.ts +14 -0
  115. package/dist/form/field-error.js +28 -0
  116. package/dist/form/field-name.d.ts +20 -0
  117. package/dist/form/field-name.js +6 -0
  118. package/dist/form/filter-form-values.js +63 -49
  119. package/dist/form/form-controller.d.ts +9 -1
  120. package/dist/form/form-controller.js +165 -160
  121. package/dist/form/form-groups.d.ts +4 -3
  122. package/dist/form/form-groups.js +79 -79
  123. package/dist/form/form-handle.d.ts +175 -0
  124. package/dist/form/form-handle.js +68 -0
  125. package/dist/form/input-binding.d.ts +56 -31
  126. package/dist/form/input-binding.js +171 -118
  127. package/dist/form/input-values.d.ts +140 -0
  128. package/dist/form/input-values.js +206 -0
  129. package/dist/form/json-text.d.ts +11 -0
  130. package/dist/form/json-text.js +62 -37
  131. package/dist/form/save-pipeline.d.ts +6 -8
  132. package/dist/form/save-pipeline.js +91 -82
  133. package/dist/form/set-form-values.d.ts +6 -0
  134. package/dist/form/set-form-values.js +10 -0
  135. package/dist/form/source.js +97 -65
  136. package/dist/form/text-of.d.ts +2 -0
  137. package/dist/form/text-of.js +7 -0
  138. package/dist/form/validators.d.ts +58 -19
  139. package/dist/form/validators.js +97 -71
  140. package/dist/geo/bbox.d.ts +5 -4
  141. package/dist/geo/bbox.js +69 -48
  142. package/dist/geo/bounds.d.ts +14 -3
  143. package/dist/geo/bounds.js +98 -68
  144. package/dist/geo/constraints.js +88 -93
  145. package/dist/geo/coordinates.d.ts +10 -0
  146. package/dist/geo/coordinates.js +44 -23
  147. package/dist/geo/editor.d.ts +13 -13
  148. package/dist/geo/editor.js +176 -153
  149. package/dist/geo/geocoding.d.ts +4 -4
  150. package/dist/geo/geocoding.js +38 -41
  151. package/dist/geo/geojson.d.ts +12 -74
  152. package/dist/geo/geojson.js +52 -40
  153. package/dist/geo/geometry-math.d.ts +4 -4
  154. package/dist/geo/geometry-math.js +100 -93
  155. package/dist/geo/index.d.ts +9 -8
  156. package/dist/geo/map-layers.d.ts +42 -7
  157. package/dist/geo/map-layers.js +88 -62
  158. package/dist/geo/map-style.d.ts +12 -0
  159. package/dist/geo/map-style.js +43 -32
  160. package/dist/geo/messages.js +48 -45
  161. package/dist/geo/nominatim.js +88 -80
  162. package/dist/geo/osm-presets.d.ts +4 -3
  163. package/dist/geo/osm-presets.js +108 -86
  164. package/dist/geo/osm-tags.js +14 -12
  165. package/dist/geo/overpass.d.ts +14 -2
  166. package/dist/geo/overpass.js +83 -90
  167. package/dist/geo/shape-value.d.ts +9 -5
  168. package/dist/geo/shape-value.js +169 -146
  169. package/dist/geo/snap-to-roads.d.ts +3 -3
  170. package/dist/geo/snap-to-roads.js +55 -50
  171. package/dist/geo/throttle.js +36 -39
  172. package/dist/guards.d.ts +44 -0
  173. package/dist/guards.js +77 -0
  174. package/dist/i18n/default-messages.js +34 -29
  175. package/dist/i18n/english-messages.d.ts +172 -0
  176. package/dist/i18n/english-messages.js +359 -189
  177. package/dist/i18n/i18n.d.ts +2 -2
  178. package/dist/i18n/i18n.js +47 -60
  179. package/dist/i18n/inflection.d.ts +34 -0
  180. package/dist/i18n/inflection.js +82 -0
  181. package/dist/i18n/messages.d.ts +10 -2
  182. package/dist/i18n/messages.js +17 -4
  183. package/dist/i18n/missing-keys.d.ts +4 -0
  184. package/dist/i18n/missing-keys.js +70 -62
  185. package/dist/i18n/translatable.d.ts +16 -1
  186. package/dist/i18n/translatable.js +40 -47
  187. package/dist/index.d.ts +87 -39
  188. package/dist/index.js +54 -301
  189. package/dist/json/deep-equal.d.ts +8 -0
  190. package/dist/json/deep-equal.js +24 -0
  191. package/dist/json/strip-json-comments.js +38 -42
  192. package/dist/noop.d.ts +2 -0
  193. package/dist/noop.js +4 -0
  194. package/dist/notification/error-message.d.ts +2 -0
  195. package/dist/notification/error-message.js +10 -0
  196. package/dist/notification/notification-store.d.ts +3 -2
  197. package/dist/notification/notification-store.js +41 -45
  198. package/dist/onboarding-tour/placement.d.ts +2 -0
  199. package/dist/onboarding-tour/placement.js +65 -78
  200. package/dist/onboarding-tour/tour-controller.js +46 -54
  201. package/dist/realtime/add-events-for-mutations.js +75 -81
  202. package/dist/realtime/announce-lock.js +16 -13
  203. package/dist/realtime/broadcast-channel-transport.js +65 -59
  204. package/dist/realtime/copy-members.js +22 -14
  205. package/dist/realtime/in-memory-lock-provider.js +40 -48
  206. package/dist/realtime/live.js +95 -101
  207. package/dist/realtime/lock-options.js +16 -16
  208. package/dist/realtime/lock-session.js +91 -78
  209. package/dist/realtime/memory-transport.js +25 -25
  210. package/dist/realtime/presence.js +120 -122
  211. package/dist/realtime/realtime-data-provider.d.ts +10 -0
  212. package/dist/realtime/realtime-data-provider.js +45 -42
  213. package/dist/realtime/reconnect-backoff.js +17 -0
  214. package/dist/realtime/sse-transport.d.ts +31 -0
  215. package/dist/realtime/sse-transport.js +235 -0
  216. package/dist/realtime/status-store.js +23 -28
  217. package/dist/realtime/topic-hub.js +62 -71
  218. package/dist/realtime/topics.js +10 -12
  219. package/dist/realtime/types.js +10 -12
  220. package/dist/realtime/unread-count.js +21 -21
  221. package/dist/realtime/websocket-transport.d.ts +32 -0
  222. package/dist/realtime/websocket-transport.js +306 -0
  223. package/dist/resource/describe-resource.d.ts +2 -0
  224. package/dist/resource/describe-resource.js +247 -219
  225. package/dist/resource/get-at-path.js +29 -14
  226. package/dist/resource/identifier.d.ts +18 -0
  227. package/dist/resource/identifier.js +27 -0
  228. package/dist/resource/record.d.ts +4 -0
  229. package/dist/resource/record.js +10 -0
  230. package/dist/resource/register.d.ts +7 -0
  231. package/dist/resource/registry-store.d.ts +45 -15
  232. package/dist/resource/registry-store.js +67 -60
  233. package/dist/resource/registry.d.ts +10 -0
  234. package/dist/resource/registry.js +8 -4
  235. package/dist/resource/source-path.d.ts +23 -2
  236. package/dist/rich-text/rich-text-extras.d.ts +134 -0
  237. package/dist/rich-text/rich-text-extras.js +181 -0
  238. package/dist/rich-text/rich-text-image.d.ts +124 -0
  239. package/dist/rich-text/rich-text-image.js +163 -0
  240. package/dist/rich-text/rich-text.d.ts +6 -0
  241. package/dist/rich-text/rich-text.js +125 -88
  242. package/dist/router/location.d.ts +24 -4
  243. package/dist/router/location.js +43 -18
  244. package/dist/router/match.js +47 -39
  245. package/dist/router/paths.js +40 -34
  246. package/dist/router/record-from-location.d.ts +1 -1
  247. package/dist/router/record-from-location.js +25 -17
  248. package/dist/store/adapters.js +51 -55
  249. package/dist/store/micro-store.js +57 -59
  250. package/dist/store/shallow-equal.d.ts +12 -0
  251. package/dist/store/shallow-equal.js +20 -0
  252. package/dist/store/store.js +54 -57
  253. package/dist/ui-defaults.d.ts +15 -0
  254. package/dist/ui-defaults.js +17 -0
  255. package/package.json +13 -11
@@ -1,274 +1,305 @@
1
- import { MutationObserver, QueryObserver } from "@tanstack/query-core";
2
1
  import { ValidationError } from "../data/errors.js";
3
2
  import { createMutationOptions, deleteMutationOptions, updateMutationOptions } from "../data/mutation-options.js";
4
3
  import { getOneOptions } from "../data/query-options.js";
5
4
  import { createMicroStore } from "../store/micro-store.js";
6
5
  import { resolveNotificationKey } from "./translate-fallback.js";
6
+ import { MutationObserver, QueryObserver } from "@tanstack/query-core";
7
+ // src/controller/record-controllers.ts
8
+ /**
9
+ * THE shared save/delete flow (behaviors 9 + 10) for the edit, create and
10
+ * delete controllers — one implementation of: the `saving` flag (counter-
11
+ * guarded so overlapping mutations cannot flicker it false early), attached
12
+ * vs transient observer selection, `ValidationError` RESOLVING with
13
+ * `fieldErrors` (no notify), other errors notifying
14
+ * `chelone.notification.http_error`, caller onSuccess/onError REPLACING the
15
+ * defaults, the success notify, and MutationObserver release.
16
+ *
17
+ * MutationObserver RELEASE is explicit: mutate() attaches the observer to the
18
+ * built Mutation (clearing its GC timeout) and query-core only detaches an
19
+ * observer via onUnsubscribe (needs listeners), reset(), or a later mutate().
20
+ * An unsubscribed observer therefore pins its last mutation (full data +
21
+ * previousData) in the MutationCache forever unless we reset() — done in the
22
+ * attach-cycle teardown and after each mutation settles (the finally block).
23
+ */
7
24
  function createMutationFlow(config) {
8
- let pending = 0;
9
- return async function run(variables, handlers = {}) {
10
- if (config.isDestroyed()) return void 0;
11
- const observer = config.attachedObserver() ?? config.makeObserver();
12
- pending += 1;
13
- config.store.setState({ saving: true });
14
- let result;
15
- try {
16
- result = await observer.mutate(variables);
17
- } catch (error) {
18
- if (handlers.onError) {
19
- await handlers.onError(error);
20
- } else if (!(error instanceof ValidationError)) {
21
- config.ctx.notifications?.notify("chelone.notification.http_error", {
22
- type: "error",
23
- messageArgs: { _: String(error) }
24
- });
25
- }
26
- return error instanceof ValidationError ? error.fieldErrors : void 0;
27
- } finally {
28
- pending -= 1;
29
- if (pending === 0) config.store.setState({ saving: false });
30
- if (observer !== config.attachedObserver() || pending === 0) {
31
- observer.reset();
32
- }
33
- }
34
- if (handlers.onSaved) await handlers.onSaved(result);
35
- if (handlers.onSuccess) {
36
- await handlers.onSuccess(result);
37
- return void 0;
38
- }
39
- config.ctx.notifications?.notify(
40
- resolveNotificationKey(
41
- config.ctx.translate,
42
- config.resource,
43
- config.successEvent
44
- ),
45
- { type: "success", messageArgs: { smart_count: 1 } }
46
- );
47
- config.onDefaultSuccess(result, variables);
48
- return void 0;
49
- };
25
+ /** Overlapping mutations: `saving` goes false only when the LAST one settles. */
26
+ let pending = 0;
27
+ return async function run(variables, handlers = {}) {
28
+ if (config.isDestroyed()) return void 0;
29
+ const observer = config.attachedObserver() ?? config.makeObserver();
30
+ pending += 1;
31
+ config.store.setState({ saving: true });
32
+ let result;
33
+ try {
34
+ result = await observer.mutate(variables);
35
+ } catch (error) {
36
+ if (handlers.onError) await handlers.onError(error);
37
+ else if (!(error instanceof ValidationError)) config.ctx.notifications?.notify("chelone.notification.http_error", {
38
+ type: "error",
39
+ messageArgs: { _: String(error) }
40
+ });
41
+ return error instanceof ValidationError ? error.fieldErrors : void 0;
42
+ } finally {
43
+ pending -= 1;
44
+ if (pending === 0) config.store.setState({ saving: false });
45
+ if (observer !== config.attachedObserver() || pending === 0) observer.reset();
46
+ }
47
+ if (handlers.onSaved) await handlers.onSaved(result);
48
+ if (handlers.onSuccess) {
49
+ await handlers.onSuccess(result);
50
+ return;
51
+ }
52
+ config.ctx.notifications?.notify(resolveNotificationKey(config.ctx.translate, config.resource, config.successEvent), {
53
+ type: "success",
54
+ messageArgs: { smart_count: 1 }
55
+ });
56
+ config.onDefaultSuccess(result, variables);
57
+ };
50
58
  }
59
+ /**
60
+ * Shared getOne wiring for the edit + show controllers: returns the acquire()
61
+ * used as the micro-store's `onSubscribed` (post-4R pattern — construction is
62
+ * INERT; the QueryObserver exists only while the store has subscribers).
63
+ *
64
+ * ra-core behavior 8: `retry: false`; a fetched record whose id does not
65
+ * LOOSELY match the requested id becomes an error state (a local consistency
66
+ * error — no notify); an actual fetch error notifies
67
+ * `chelone.notification.item_doesnt_exist` and requests a list redirect.
68
+ */
51
69
  function createRecordFetch(ctx, resource, options, store) {
52
- let lastNotifiedError = null;
53
- let mismatchError = null;
54
- const onResult = (result) => {
55
- const record = result.data;
56
- const recordId = record === void 0 ? void 0 : record.id;
57
- if (record !== void 0 && String(recordId) !== String(options.id)) {
58
- mismatchError ??= new Error(
59
- `createRecordFetch: fetched record id (${String(recordId)}) does not match the requested id (${String(options.id)})`
60
- );
61
- store.setState({
62
- record: void 0,
63
- isPending: false,
64
- isFetching: result.isFetching,
65
- error: mismatchError
66
- });
67
- return;
68
- }
69
- mismatchError = null;
70
- store.setState({
71
- record,
72
- isPending: result.isPending,
73
- isFetching: result.isFetching,
74
- error: result.error ?? null
75
- });
76
- if (result.error) {
77
- if (result.error !== lastNotifiedError) {
78
- lastNotifiedError = result.error;
79
- ctx.notifications?.notify("chelone.notification.item_doesnt_exist", {
80
- type: "error"
81
- });
82
- options.onRedirect?.("list", resource);
83
- }
84
- } else {
85
- lastNotifiedError = null;
86
- }
87
- };
88
- return function acquire() {
89
- const observer = new QueryObserver(ctx.queryClient, {
90
- ...getOneOptions(ctx, resource, { id: options.id }),
91
- retry: false,
92
- enabled: options.enabled ?? true
93
- });
94
- const unsubscribe = observer.subscribe(onResult);
95
- onResult(observer.getCurrentResult());
96
- return unsubscribe;
97
- };
70
+ let lastNotifiedError = null;
71
+ /** Cached so repeated observer emissions keep a STABLE error reference. */
72
+ let mismatchError = null;
73
+ const onResult = (result) => {
74
+ const record = result.data;
75
+ const recordId = record === void 0 ? void 0 : record.id;
76
+ if (record !== void 0 && String(recordId) !== String(options.id)) {
77
+ mismatchError ??= /* @__PURE__ */ new Error(`createRecordFetch: fetched record id (${String(recordId)}) does not match the requested id (${String(options.id)})`);
78
+ store.setState({
79
+ record: void 0,
80
+ isPending: false,
81
+ isFetching: result.isFetching,
82
+ error: mismatchError
83
+ });
84
+ return;
85
+ }
86
+ mismatchError = null;
87
+ store.setState({
88
+ record,
89
+ isPending: result.isPending,
90
+ isFetching: result.isFetching,
91
+ error: result.error ?? null
92
+ });
93
+ if (result.error) {
94
+ if (result.error !== lastNotifiedError) {
95
+ lastNotifiedError = result.error;
96
+ ctx.notifications?.notify("chelone.notification.item_doesnt_exist", { type: "error" });
97
+ options.onRedirect?.("list", resource);
98
+ }
99
+ } else lastNotifiedError = null;
100
+ };
101
+ return function acquire() {
102
+ const observer = new QueryObserver(ctx.queryClient, {
103
+ ...getOneOptions(ctx, resource, { id: options.id }),
104
+ retry: false,
105
+ enabled: options.enabled ?? true
106
+ });
107
+ const unsubscribe = observer.subscribe(onResult);
108
+ onResult(observer.getCurrentResult());
109
+ return unsubscribe;
110
+ };
98
111
  }
112
+ /**
113
+ * Vanilla show controller: one getOne QueryObserver mapped into a micro-store.
114
+ * Construction is INERT; acquisition on first subscribe, release on last
115
+ * unsubscribe AND on destroy-while-attached (micro-store lifecycle).
116
+ */
99
117
  function createShowController(ctx, resource, options) {
100
- function acquire() {
101
- return acquireFetch();
102
- }
103
- const store = createMicroStore(
104
- { record: void 0, isPending: true, isFetching: false, error: null },
105
- { onSubscribed: acquire }
106
- );
107
- const acquireFetch = createRecordFetch(ctx, resource, options, store);
108
- return store;
118
+ function acquire() {
119
+ return acquireFetch();
120
+ }
121
+ const store = createMicroStore({
122
+ record: void 0,
123
+ isPending: true,
124
+ isFetching: false,
125
+ error: null
126
+ }, { onSubscribed: acquire });
127
+ const acquireFetch = createRecordFetch(ctx, resource, options, store);
128
+ return store;
109
129
  }
130
+ /**
131
+ * Vanilla edit controller: the show-shaped record fetch plus a `save` flow
132
+ * backed by a MutationObserver over `updateMutationOptions`.
133
+ *
134
+ * Lifecycle: both observers are ACQUIRED in the micro-store's onSubscribed
135
+ * and released on last-unsubscribe/destroy. The MutationObserver is not
136
+ * subscribed — `saving` has a single writer (the shared mutation flow), which
137
+ * also keeps the flag truthful for detached imperative saves. Save semantics
138
+ * (validation errors, notify, release) live in `createMutationFlow`.
139
+ */
110
140
  function createEditController(ctx, resource, options) {
111
- const store = createMicroStore(
112
- {
113
- record: void 0,
114
- isPending: true,
115
- isFetching: false,
116
- error: null,
117
- saving: false
118
- },
119
- { onSubscribed: acquire }
120
- );
121
- let destroyed = false;
122
- store.onDestroy(() => {
123
- destroyed = true;
124
- });
125
- const acquireFetch = createRecordFetch(ctx, resource, options, store);
126
- const makeMutationObserver = () => new MutationObserver(
127
- ctx.queryClient,
128
- updateMutationOptions(ctx, resource, {
129
- mutationMode: options.mutationMode
130
- })
131
- );
132
- let mutationObserver = null;
133
- function acquire() {
134
- const observer = makeMutationObserver();
135
- mutationObserver = observer;
136
- const releaseFetch = acquireFetch();
137
- return () => {
138
- releaseFetch();
139
- observer.reset();
140
- mutationObserver = null;
141
- };
142
- }
143
- const runSave = createMutationFlow({
144
- ctx,
145
- resource,
146
- store,
147
- isDestroyed: () => destroyed,
148
- attachedObserver: () => mutationObserver,
149
- makeObserver: makeMutationObserver,
150
- successEvent: "updated",
151
- onDefaultSuccess: (result) => {
152
- options.onRedirect?.("list", resource, options.id, result);
153
- }
154
- });
155
- const save = async (data, saveOptions = {}) => {
156
- if (destroyed) return void 0;
157
- const previousData = store.getSnapshot().record;
158
- const transform = saveOptions.transform ?? options.transform;
159
- const transformed = transform ? await transform(data, { previousData }) : data;
160
- return runSave(
161
- {
162
- id: options.id,
163
- data: transformed,
164
- previousData,
165
- meta: saveOptions.meta
166
- },
167
- saveOptions
168
- );
169
- };
170
- return { ...store, save };
141
+ const store = createMicroStore({
142
+ record: void 0,
143
+ isPending: true,
144
+ isFetching: false,
145
+ error: null,
146
+ saving: false
147
+ }, { onSubscribed: acquire });
148
+ let destroyed = false;
149
+ store.onDestroy(() => {
150
+ destroyed = true;
151
+ });
152
+ const acquireFetch = createRecordFetch(ctx, resource, options, store);
153
+ const makeMutationObserver = () => new MutationObserver(ctx.queryClient, updateMutationOptions(ctx, resource, { mutationMode: options.mutationMode }));
154
+ /** Non-null exactly while attached. */
155
+ let mutationObserver = null;
156
+ function acquire() {
157
+ const observer = makeMutationObserver();
158
+ mutationObserver = observer;
159
+ const releaseFetch = acquireFetch();
160
+ return () => {
161
+ releaseFetch();
162
+ observer.reset();
163
+ mutationObserver = null;
164
+ };
165
+ }
166
+ const runSave = createMutationFlow({
167
+ ctx,
168
+ resource,
169
+ store,
170
+ isDestroyed: () => destroyed,
171
+ attachedObserver: () => mutationObserver,
172
+ makeObserver: makeMutationObserver,
173
+ successEvent: "updated",
174
+ onDefaultSuccess: (result) => {
175
+ options.onRedirect?.("list", resource, options.id, result);
176
+ }
177
+ });
178
+ const save = async (data, saveOptions = {}) => {
179
+ if (destroyed) return void 0;
180
+ const previousData = store.getSnapshot().record;
181
+ const transform = saveOptions.transform ?? options.transform;
182
+ const transformed = transform ? await transform(data, { previousData }) : data;
183
+ return runSave({
184
+ id: options.id,
185
+ data: transformed,
186
+ previousData,
187
+ meta: saveOptions.meta
188
+ }, saveOptions);
189
+ };
190
+ return {
191
+ ...store,
192
+ save
193
+ };
171
194
  }
195
+ /**
196
+ * Vanilla create controller: no fetch AT ALL — construction is trivially
197
+ * inert and `isPending` is false from birth. The only acquired resource is
198
+ * the shared MutationObserver (onSubscribed attach / last-unsubscribe or
199
+ * destroy release); detached saves use a transient observer, exactly like
200
+ * edit. Save semantics live in `createMutationFlow`.
201
+ */
172
202
  function createCreateController(ctx, resource, options = {}) {
173
- const store = createMicroStore(
174
- { record: options.record ?? {}, saving: false, isPending: false },
175
- { onSubscribed: acquire }
176
- );
177
- let destroyed = false;
178
- store.onDestroy(() => {
179
- destroyed = true;
180
- });
181
- const makeMutationObserver = () => new MutationObserver(ctx.queryClient, createMutationOptions(ctx, resource));
182
- let mutationObserver = null;
183
- function acquire() {
184
- const observer = makeMutationObserver();
185
- mutationObserver = observer;
186
- return () => {
187
- observer.reset();
188
- mutationObserver = null;
189
- };
190
- }
191
- const runSave = createMutationFlow({
192
- ctx,
193
- resource,
194
- store,
195
- isDestroyed: () => destroyed,
196
- attachedObserver: () => mutationObserver,
197
- makeObserver: makeMutationObserver,
198
- successEvent: "created",
199
- onDefaultSuccess: (result) => {
200
- options.onRedirect?.(
201
- "edit",
202
- resource,
203
- result.id,
204
- result
205
- );
206
- }
207
- });
208
- const save = async (data, saveOptions = {}) => {
209
- if (destroyed) return void 0;
210
- const transform = saveOptions.transform ?? options.transform;
211
- const transformed = transform ? await transform(data) : data;
212
- return runSave({ data: transformed, meta: saveOptions.meta }, saveOptions);
213
- };
214
- return { ...store, save };
203
+ const store = createMicroStore({
204
+ record: options.record ?? {},
205
+ saving: false,
206
+ isPending: false
207
+ }, { onSubscribed: acquire });
208
+ let destroyed = false;
209
+ store.onDestroy(() => {
210
+ destroyed = true;
211
+ });
212
+ const makeMutationObserver = () => new MutationObserver(ctx.queryClient, createMutationOptions(ctx, resource));
213
+ /** Non-null exactly while attached. */
214
+ let mutationObserver = null;
215
+ function acquire() {
216
+ const observer = makeMutationObserver();
217
+ mutationObserver = observer;
218
+ return () => {
219
+ observer.reset();
220
+ mutationObserver = null;
221
+ };
222
+ }
223
+ const runSave = createMutationFlow({
224
+ ctx,
225
+ resource,
226
+ store,
227
+ isDestroyed: () => destroyed,
228
+ attachedObserver: () => mutationObserver,
229
+ makeObserver: makeMutationObserver,
230
+ successEvent: "created",
231
+ onDefaultSuccess: (result) => {
232
+ options.onRedirect?.("edit", resource, result.id, result);
233
+ }
234
+ });
235
+ const save = async (data, saveOptions = {}) => {
236
+ if (destroyed) return void 0;
237
+ const transform = saveOptions.transform ?? options.transform;
238
+ const transformed = transform ? await transform(data) : data;
239
+ return runSave({
240
+ data: transformed,
241
+ meta: saveOptions.meta
242
+ }, saveOptions);
243
+ };
244
+ return {
245
+ ...store,
246
+ save
247
+ };
215
248
  }
249
+ /**
250
+ * Vanilla delete controller: no fetch — like create, construction is
251
+ * trivially inert and the only acquired resource is the shared
252
+ * MutationObserver. Delete semantics live in `createMutationFlow`; the
253
+ * delete-specific default success additionally cleans the KV selection so a
254
+ * deleted row cannot linger selected in list views.
255
+ */
216
256
  function createDeleteController(ctx, resource, options = {}) {
217
- const store = createMicroStore(
218
- { saving: false },
219
- { onSubscribed: acquire }
220
- );
221
- let destroyed = false;
222
- store.onDestroy(() => {
223
- destroyed = true;
224
- });
225
- const makeMutationObserver = () => new MutationObserver(ctx.queryClient, deleteMutationOptions(ctx, resource));
226
- let mutationObserver = null;
227
- function acquire() {
228
- const observer = makeMutationObserver();
229
- mutationObserver = observer;
230
- return () => {
231
- observer.reset();
232
- mutationObserver = null;
233
- };
234
- }
235
- const stripSelection = (id) => {
236
- const kv = ctx.store;
237
- if (!kv) return;
238
- const key = `${resource}.selectedIds`;
239
- const selection = kv.getItem(key);
240
- if (!selection?.includes(id)) return;
241
- kv.setItem(
242
- key,
243
- selection.filter((existing) => existing !== id)
244
- );
245
- };
246
- const runDelete = createMutationFlow({
247
- ctx,
248
- resource,
249
- store,
250
- isDestroyed: () => destroyed,
251
- attachedObserver: () => mutationObserver,
252
- makeObserver: makeMutationObserver,
253
- successEvent: "deleted",
254
- onDefaultSuccess: (_result, variables) => {
255
- options.onRedirect?.("list", resource);
256
- stripSelection(variables.id);
257
- }
258
- });
259
- const deleteOne = async (id, deleteOptions = {}) => runDelete(
260
- {
261
- id,
262
- previousData: deleteOptions.previousData,
263
- meta: deleteOptions.meta
264
- },
265
- deleteOptions
266
- );
267
- return { ...store, deleteOne };
257
+ const store = createMicroStore({ saving: false }, { onSubscribed: acquire });
258
+ let destroyed = false;
259
+ store.onDestroy(() => {
260
+ destroyed = true;
261
+ });
262
+ const makeMutationObserver = () => new MutationObserver(ctx.queryClient, deleteMutationOptions(ctx, resource));
263
+ /** Non-null exactly while attached. */
264
+ let mutationObserver = null;
265
+ function acquire() {
266
+ const observer = makeMutationObserver();
267
+ mutationObserver = observer;
268
+ return () => {
269
+ observer.reset();
270
+ mutationObserver = null;
271
+ };
272
+ }
273
+ /** Strips the REQUESTED id from the KV selection — immutably (fresh array). */
274
+ const stripSelection = (id) => {
275
+ const kv = ctx.store;
276
+ if (!kv) return;
277
+ const key = `${resource}.selectedIds`;
278
+ const selection = kv.getItem(key);
279
+ if (!selection?.includes(id)) return;
280
+ kv.setItem(key, selection.filter((existing) => existing !== id));
281
+ };
282
+ const runDelete = createMutationFlow({
283
+ ctx,
284
+ resource,
285
+ store,
286
+ isDestroyed: () => destroyed,
287
+ attachedObserver: () => mutationObserver,
288
+ makeObserver: makeMutationObserver,
289
+ successEvent: "deleted",
290
+ onDefaultSuccess: (_result, variables) => {
291
+ options.onRedirect?.("list", resource);
292
+ stripSelection(variables.id);
293
+ }
294
+ });
295
+ const deleteOne = async (id, deleteOptions = {}) => runDelete({
296
+ id,
297
+ previousData: deleteOptions.previousData,
298
+ meta: deleteOptions.meta
299
+ }, deleteOptions);
300
+ return {
301
+ ...store,
302
+ deleteOne
303
+ };
268
304
  }
269
- export {
270
- createCreateController,
271
- createDeleteController,
272
- createEditController,
273
- createShowController
274
- };
305
+ export { createCreateController, createDeleteController, createEditController, createShowController };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pure rules for a record diff. No react, no vue: both DiffViewer adapters
3
+ * mark the same field the same way and print the same default text.
4
+ */
5
+ import type { SourceProp } from "../resource/source-path.js";
6
+ export type DiffStatus = "unchanged" | "added" | "removed" | "changed";
7
+ export interface RecordDiffRow {
8
+ source: string;
9
+ status: DiffStatus;
10
+ before: unknown;
11
+ after: unknown;
12
+ }
13
+ /**
14
+ * One row per source, in the order given. Without `sources` the rows are the
15
+ * top-level keys of `before` in its order, then the keys only `after` has.
16
+ * `null` and `undefined` both count as missing; an empty string is a value.
17
+ * Neither record is changed.
18
+ */
19
+ export declare function diffRecords<R>(before: R, after: R, sources?: readonly SourceProp<R>[]): RecordDiffRow[];
20
+ /** The text a viewer shows for a value when the app gives no renderer. */
21
+ export declare function formatDiffValue(value: unknown): string;
@@ -0,0 +1,50 @@
1
+ import { isPlainObject, isRecord } from "../guards.js";
2
+ import { getAtPath } from "../resource/get-at-path.js";
3
+ import { deepEqual } from "./deep-equal.js";
4
+ // src/controller/record-diff.ts
5
+ /**
6
+ * Pure rules for a record diff. No react, no vue: both DiffViewer adapters
7
+ * mark the same field the same way and print the same default text.
8
+ */
9
+ function topLevelKeys(record) {
10
+ return isRecord(record) ? Object.keys(record) : [];
11
+ }
12
+ function statusOf(before, after) {
13
+ const hadBefore = before != null;
14
+ const hasAfter = after != null;
15
+ if (!hadBefore && !hasAfter) return "unchanged";
16
+ if (!hadBefore) return "added";
17
+ if (!hasAfter) return "removed";
18
+ return deepEqual(before, after) ? "unchanged" : "changed";
19
+ }
20
+ /**
21
+ * One row per source, in the order given. Without `sources` the rows are the
22
+ * top-level keys of `before` in its order, then the keys only `after` has.
23
+ * `null` and `undefined` both count as missing; an empty string is a value.
24
+ * Neither record is changed.
25
+ */
26
+ function diffRecords(before, after, sources) {
27
+ const beforeKeys = topLevelKeys(before);
28
+ return (sources ?? [...beforeKeys, ...topLevelKeys(after).filter((key) => !beforeKeys.includes(key))]).map((source) => {
29
+ const was = getAtPath(before, source);
30
+ const now = getAtPath(after, source);
31
+ return {
32
+ source,
33
+ status: statusOf(was, now),
34
+ before: was,
35
+ after: now
36
+ };
37
+ });
38
+ }
39
+ /** The text a viewer shows for a value when the app gives no renderer. */
40
+ function formatDiffValue(value) {
41
+ if (value == null) return "";
42
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
43
+ if (Array.isArray(value) || isPlainObject(value)) try {
44
+ return JSON.stringify(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ return String(value);
49
+ }
50
+ export { diffRecords, formatDiffValue };