@marktiderman/genesis-core 1.8.0 → 1.10.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 (68) hide show
  1. package/README.md +236 -0
  2. package/dist/chunk-52T2EYXH.js +52 -0
  3. package/dist/chunk-52T2EYXH.js.map +1 -0
  4. package/dist/chunk-5ORRAXIV.js +30 -0
  5. package/dist/chunk-5ORRAXIV.js.map +1 -0
  6. package/dist/{chunk-ZKMAU6NN.js → chunk-BGTXN2E6.js} +5 -4
  7. package/dist/chunk-BGTXN2E6.js.map +1 -0
  8. package/dist/chunk-DF2UWEEB.js +38 -0
  9. package/dist/chunk-DF2UWEEB.js.map +1 -0
  10. package/dist/{chunk-XKLYDAT5.js → chunk-EF5BKT4B.js} +98 -21
  11. package/dist/chunk-EF5BKT4B.js.map +1 -0
  12. package/dist/chunk-HBAPYA5K.js +257 -0
  13. package/dist/chunk-HBAPYA5K.js.map +1 -0
  14. package/dist/{chunk-QFR6YJ3R.js → chunk-NMFABW4Y.js} +6 -5
  15. package/dist/chunk-NMFABW4Y.js.map +1 -0
  16. package/dist/{chunk-ADD2RIGA.js → chunk-NZPJI7FC.js} +4 -3
  17. package/dist/chunk-NZPJI7FC.js.map +1 -0
  18. package/dist/{chunk-SQR4GFRM.js → chunk-OSPLIKSH.js} +7 -7
  19. package/dist/{chunk-SQR4GFRM.js.map → chunk-OSPLIKSH.js.map} +1 -1
  20. package/dist/chunk-RCAP3Q4T.js +75 -0
  21. package/dist/chunk-RCAP3Q4T.js.map +1 -0
  22. package/dist/{chunk-OBUBP4BY.js → chunk-S2ZXUNXB.js} +30 -6
  23. package/dist/chunk-S2ZXUNXB.js.map +1 -0
  24. package/dist/dates.d.ts +84 -0
  25. package/dist/dates.js +117 -0
  26. package/dist/dates.js.map +1 -0
  27. package/dist/hooks/draft-controller.d.ts +158 -0
  28. package/dist/hooks/draft-controller.js +4 -0
  29. package/dist/hooks/draft-controller.js.map +1 -0
  30. package/dist/hooks/index.d.ts +45 -3
  31. package/dist/hooks/index.js +15 -11
  32. package/dist/hooks/mutation-mode.d.ts +14 -2
  33. package/dist/hooks/mutation-mode.js +2 -1
  34. package/dist/hooks/related-keys.d.ts +1 -1
  35. package/dist/hooks/use-draft.d.ts +33 -0
  36. package/dist/hooks/use-draft.js +5 -0
  37. package/dist/hooks/use-draft.js.map +1 -0
  38. package/dist/hooks/use-infinite-resource.d.ts +1 -1
  39. package/dist/hooks/use-infinite-resource.js +2 -1
  40. package/dist/hooks/use-live.d.ts +1 -1
  41. package/dist/hooks/use-one.d.ts +1 -1
  42. package/dist/hooks/use-one.js +2 -1
  43. package/dist/hooks/use-resource-form.d.ts +1 -1
  44. package/dist/hooks/use-resource-form.js +8 -7
  45. package/dist/hooks/use-resource.d.ts +1 -1
  46. package/dist/hooks/use-resource.js +5 -4
  47. package/dist/hooks/use-write.d.ts +45 -0
  48. package/dist/hooks/use-write.js +4 -0
  49. package/dist/hooks/use-write.js.map +1 -0
  50. package/dist/index.d.ts +78 -5
  51. package/dist/index.js +17 -13
  52. package/dist/provider/index.d.ts +85 -5
  53. package/dist/provider/index.js +3 -3
  54. package/dist/read-outcome-BnboMYOV.d.ts +30 -0
  55. package/dist/schema/index.d.ts +3 -2
  56. package/dist/testing/index.d.ts +102 -0
  57. package/dist/testing/index.js +210 -0
  58. package/dist/testing/index.js.map +1 -0
  59. package/dist/{types-CS9UpxQS.d.ts → types-B9DDfvWa.d.ts} +1 -1
  60. package/dist/{read-outcome-B6nACXZ7.d.ts → write-outcome-B2fE29yZ.d.ts} +1 -30
  61. package/package.json +14 -1
  62. package/dist/chunk-ADD2RIGA.js.map +0 -1
  63. package/dist/chunk-OBUBP4BY.js.map +0 -1
  64. package/dist/chunk-QFR6YJ3R.js.map +0 -1
  65. package/dist/chunk-V3GDAELU.js +0 -3
  66. package/dist/chunk-V3GDAELU.js.map +0 -1
  67. package/dist/chunk-XKLYDAT5.js.map +0 -1
  68. package/dist/chunk-ZKMAU6NN.js.map +0 -1
package/README.md CHANGED
@@ -106,6 +106,39 @@ const provider = createSupabaseProvider(supabase, {
106
106
  Both hooks also take an `invalidates` option for keys one screen cares about:
107
107
  `useResource("tasks", { invalidates: [["me"]] })`.
108
108
 
109
+ ### Reading from a view or a function
110
+
111
+ A list often needs more than one table: a join, a count, a total. Give the resource a `source` and
112
+ keep everything else (paging, filters, search, sort, "showing N of M", cursors, live updates,
113
+ Retry): PostgREST filters a view or a set-returning function like a table. Writes still go to the
114
+ table, and realtime still watches it.
115
+
116
+ ```ts
117
+ createSupabaseProvider(supabase, {
118
+ resources: {
119
+ // A view joining tasks to people and counting comments.
120
+ tasks: { source: "tasks_with_people" },
121
+ // A function returning rows; args are read on every request.
122
+ course_progress: { source: { rpc: "group_progress", args: () => ({ group_id: currentGroupId() }) } },
123
+ // Embedded joins as the default columns (a caller's `select` still wins).
124
+ sessions: { select: "*, client:people(full_name)" },
125
+ },
126
+ });
127
+ ```
128
+
129
+ **Keep RLS on.** A plain view runs as its owner, and a `security definer` function as its definer, so
130
+ either reads past row-level security and shows every row. Create the view
131
+ `with (security_invoker = true)` and the function `security invoker` (or have it filter by
132
+ `auth.uid()` itself):
133
+
134
+ ```sql
135
+ create view public.tasks_with_people with (security_invoker = true) as
136
+ select t.*, p.full_name as person_name from public.tasks t join public.people p on p.id = t.person_id;
137
+ ```
138
+
139
+ Writes send only the table's own columns: list them as `writeColumns`, or an update keeps just the
140
+ columns the table's row has, so a form built from the view row never sends `person_name` back.
141
+
109
142
  ### Handling failures
110
143
 
111
144
  ```ts
@@ -355,3 +388,206 @@ leave; asks with `window.confirm` otherwise). For other state,
355
388
  `useUnsavedChangesGuard({ when, message, confirm })` gives the same
356
389
  `confirmLeave` and `beforeunload` handling. Neither depends on a router; see
357
390
  genesis-ui's SKILL.md "Unsaved changes" for React Router and TanStack Router.
391
+
392
+ ## Test your data contract
393
+
394
+ `@marktiderman/genesis-core/testing` checks your own provider keeps the data rules, against your
395
+ real schema (a local Supabase in CI). Its pgTAP twin is genesis-supabase's `genesis_testing`; both
396
+ use the same fixture people (`FIXTURE_USERS`: owner, staff, client, stranger), so seed them once
397
+ with `select genesis_testing.create_users();`.
398
+
399
+ Each check passes only on evidence and throws a `ContractError` otherwise, so it works in vitest,
400
+ jest or node:test. A wrong resource, a missing row or a typo fails instead of passing. The control and system
401
+ writes are real (and `expectServerOwned` puts its value back): run the checks on test data.
402
+
403
+ A refusal must read as plain words, so a provider that passes the database's raw text through (an
404
+ RLS insert refusal says `new row violates row-level security policy …`) fails `expectRefused`. Map
405
+ it to a plain message in the provider; that failure is the kit doing its job.
406
+
407
+ ```ts
408
+ import { createClient } from "@supabase/supabase-js";
409
+ import { createSupabaseProvider } from "@marktiderman/genesis-core/provider";
410
+ import {
411
+ expectBinned, expectNeverShows, expectRefused, expectServerOwned, fixtureAccessToken,
412
+ } from "@marktiderman/genesis-core/testing";
413
+
414
+ // The local stack's legacy JWT secret (`supabase status`); never a production one.
415
+ const providerFor = async (who: "owner" | "client" | "stranger") => {
416
+ const token = await fixtureAccessToken(who, { secret: process.env.SUPABASE_JWT_SECRET! });
417
+ const client = createClient(url, anonKey, { global: { headers: { Authorization: `Bearer ${token}` } } });
418
+ return createSupabaseProvider(client, { binTables: ["notes"] });
419
+ };
420
+
421
+ const client = await providerFor("client");
422
+ const owner = await providerFor("owner");
423
+ const stranger = await providerFor("stranger");
424
+ const system = createSupabaseProvider(createClient(url, serviceRoleKey)); // the server's own writes
425
+
426
+ // A write the client may not make is refused, in plain words. When RLS hides the row (it reaches
427
+ // no row), the control runs the same write as someone who may make it and must change a row.
428
+ await expectRefused((p) => p.update("tasks", { id, data: { title: "x" } }), { as: stranger, control: owner });
429
+ // A column only the server sets stays as it was (the system proves it can change it first).
430
+ await expectServerOwned({ as: client, system, resource: "tasks", id, field: "status", value: "done" });
431
+ // A private value never shows, reports included: read the list around it, not the row by id.
432
+ await expectNeverShows(client.getList("group_progress", {}), "private-marker-7");
433
+ // A delete on a bin table keeps the row.
434
+ await expectBinned({ as: client, resource: "notes", id, inBin: () => adminSeesDeleted("notes", id) });
435
+ ```
436
+
437
+ | Check | Passes when |
438
+ | --- | --- |
439
+ | `expectRefused(write, { as?, control? })` | it's refused (42501, a guard's P0001, or `forbidden` / `unauthenticated`) in plain words; reaching no row counts only when `control` makes the same write and changes a row |
440
+ | `expectAllowed(write)` | it resolves |
441
+ | `expectServerOwned({ as, system, … })` | `system` can change the value (then puts it back), and it's unchanged after `as` tries; refused or put back both count |
442
+ | `expectNeverShows(read, marker)` | the marker appears nowhere in the result (raw or JSON-escaped), or the read is refused; a read that found nothing fails |
443
+ | `expectSees(read, n)` | the read returns exactly `n` rows |
444
+ | `expectBinned({ as, resource, id, inBin })` | `as` sees the row, deletes it, can't read it back, and `inBin()` confirms it's still stored |
445
+ | `expectPlainMessage(error)` | the message has no raw database text or error codes |
446
+
447
+ ## Autosave a draft
448
+
449
+ `useDraft` saves one row as someone edits it: an answer to a questionnaire, a
450
+ note, a journal entry. The app supplies the writes (`create`, `update`), so it
451
+ works with any backend. What it guarantees:
452
+
453
+ - **When it saves:** a moment after the last change (`delayMs`, default 800).
454
+ Only one write runs at a time. The first save adds the row and later ones
455
+ change it. Nothing is written when the value is unchanged, and no row is
456
+ added for an empty value (`isEmpty`).
457
+ - **Another tab started the row:** the first write fails as a duplicate (a
458
+ `duplicate` write outcome, or `isDuplicate`). `adopt` then merges into that
459
+ row, writing only the fields changed here (`dirty`), or reports it already
460
+ finished, and the draft stops.
461
+ - **The server's copy wins** (sent elsewhere, returned by a reviewer): pass a
462
+ new `revision`. The draft starts again from `seed`, and `discarded` tells the
463
+ page that unsaved edits were dropped. `onRevision` can merge them instead.
464
+ A new revision that is only this draft's own save coming back (the same row,
465
+ holding what was saved) keeps every edit, so a revision built from the row's
466
+ id is safe across the first save's refetch. A revision that brings another
467
+ row first saves the edits pending here to the row they belong to (`idOf`,
468
+ default `row.id`).
469
+ `key` changes whenever the value is replaced from outside, so remount the
470
+ editor on it.
471
+ - **Failures:** network failures are retried with backoff. Refusals
472
+ (forbidden, gone, invalid) aren't retried; they set `status: "error"` and a
473
+ plain `error`.
474
+ - **Leaving the page** saves what's pending: on `pagehide` and `beforeunload`
475
+ (with `keepalive` passed to `update`), and on unmount.
476
+ - **`commit(write)`** is the final write, a submit. It waits for any write in
477
+ flight and stops autosave once it succeeds. If it fails, autosave carries on.
478
+ - **`enabled: false`** writes nothing: someone else's draft, or one already
479
+ sent.
480
+
481
+ ```tsx
482
+ const draft = useDraft({
483
+ seed: { row: entry, value: entry?.answers ?? {} },
484
+ revision: `${entry?.id}:${entry?.status}`,
485
+ enabled: entry?.status !== "sent",
486
+ create: (answers) => api.addEntry(answers),
487
+ update: (row, answers) => api.saveEntry(row.id, answers),
488
+ toValue: (row) => row.answers,
489
+ onSaved: (row) => queryClient.setQueryData(["entry", row.id], row),
490
+ });
491
+
492
+ <Editor key={draft.key} value={draft.value} onChange={draft.set} />
493
+ <Button onClick={() => draft.commit((row, answers) => api.send(row, answers))}>Send</Button>
494
+ ```
495
+
496
+ `DraftController` is the same logic without React, for tests or other
497
+ frameworks.
498
+
499
+ ## Query keys
500
+
501
+ Build every cache key in one place, so a write invalidates what it made stale and nothing cached for
502
+ one person shows to another.
503
+
504
+ ```ts
505
+ import { ViewerScope, defineQueryKeys, resourceKeys } from "@marktiderman/genesis-core";
506
+
507
+ export const worksheetKeys = defineQueryKeys("worksheet-items", {
508
+ // The fields that make one query distinct. Sort arrays: order never makes two keys.
509
+ lessonParts: (people: string[]) => ({ people: [...people].sort() }),
510
+ byEntry: (entryId: string) => ({ entryId }),
511
+ });
512
+
513
+ // Reading: keys exist only for a scope. `viewer` is required; `group` and `day` when the read depends on them.
514
+ useQuery({ queryKey: worksheetKeys.for({ viewer: userId, group: practiceId }).lessonParts(people), queryFn });
515
+
516
+ // Invalidating:
517
+ queryClient.invalidateQueries({ queryKey: worksheetKeys.all }); // the whole domain
518
+ queryClient.invalidateQueries({ queryKey: worksheetKeys.lessonParts.all }); // one kind
519
+ queryClient.invalidateQueries(worksheetKeys.where({ people: personId })); // every key with this person, multi-person keys included
520
+ queryClient.invalidateQueries(worksheetKeys.where({ entryId }, "byEntry")); // narrowed to one query
521
+ queryClient.invalidateQueries({ queryKey: resourceKeys("tasks").all }); // what useResource / useOne cache
522
+
523
+ // Once, around the signed-in app: screens mount only for a known viewer, and a sign-out or a new
524
+ // person unmounts them and empties the cache first. (Resource keys carry no viewer; this keeps them apart.)
525
+ <ViewerScope viewer={session === undefined ? undefined : (session?.user.id ?? null)}>
526
+ <App />
527
+ </ViewerScope>
528
+ ```
529
+
530
+ Keys are `[domain, kind, { ...fields, viewer, group?, day? }]`. `where(fields, kind?)` matches a
531
+ field that is equal (`1` matches `"1"`; objects in any key order), an array field containing the
532
+ value, or for an array value an array field containing every item. Never a substring.
533
+
534
+ With a persisted cache (a native app's saved query cache), pass the viewer it was saved for as
535
+ `persistedViewer`, or the first known viewer empties it on every cold start. Without `ViewerScope`, call `clearViewerCache(queryClient)` in the sign-in and
536
+ sign-out actions, before the session changes: it also sends the old viewer's waiting undoable
537
+ writes while the session is still theirs. `ViewerScope` drops those unsent writes instead (by then the
538
+ session is the new viewer's), so prefer `clearViewerCache` in a sign-out action when undo is on.
539
+
540
+ ## One-off writes
541
+
542
+ For the writes a resource page doesn't make: a database function, "one live row per key", or a few
543
+ writes in a row. They fail with a `DataWriteError` in plain words, like the provider's own writes.
544
+
545
+ ```ts
546
+ import { callRpc, createOrAdopt, useWrite } from "@marktiderman/genesis-core";
547
+
548
+ // A database function. Your words for a code win; a guard's own `raise exception` (P0001) keeps its
549
+ // words; 42501, 22023 and a missing function get plain defaults; anything else a generic sentence.
550
+ await callRpc(supabase, "release_step", { item_id }, { messages: { "55000": "This course hasn't started yet." } });
551
+
552
+ // One live row per key: create it, or adopt the row already there (merging in what you return).
553
+ const { row, adopted } = await createOrAdopt(provider, "lesson_notes", { person_id, lesson_id, body }, {
554
+ key: ["person_id", "lesson_id"],
555
+ merge: (existing) => (existing.body ? null : { body }),
556
+ });
557
+
558
+ // In a component: pending, plain error words, and invalidation after a success.
559
+ const release = useWrite((itemId: string) => callRpc(supabase, "release_step", { item_id: itemId }), {
560
+ invalidates: [["course-steps"], resourceKeys("tasks").all],
561
+ });
562
+ <Button onClick={() => release.fire(id)} disabled={release.pending}>Release</Button>
563
+ ```
564
+
565
+ `run(args)` rejects on failure; `fire(args)` never does (the error shows in `error`). One write runs at a
566
+ time: a second click while it runs gets the same promise, not a second write.
567
+
568
+ ## Days and time zones (`/dates`)
569
+
570
+ A day is a `YYYY-MM-DD` string: the date on one time zone's clock. `new Date().toISOString().slice(0, 10)`
571
+ gives the day in UTC. For an evening in the Americas that is already tomorrow, and for a morning in
572
+ Asia it is still yesterday. A server's own zone is usually UTC too. `@marktiderman/genesis-core/dates`
573
+ always takes the zone, so you can't fall into that by accident:
574
+
575
+ ```ts
576
+ import { personDay, dayIn, dayRangeIn, addDays, startOfWeek } from "@marktiderman/genesis-core/dates";
577
+
578
+ const today = personDay(profile); // the person's saved zone, else the device's, else UTC
579
+ const yesterday = addDays(today, -1);
580
+ const week = startOfWeek(today); // Monday; startOfWeek(today, 0) for Sunday
581
+ const { start, end } = dayRangeIn(today, profile.timeZone); // the instants, for created_at >= start and < end
582
+ ```
583
+
584
+ - `personDay(person, at?)` and `personTimeZone(person)`: the person's `timeZone` when the runtime
585
+ knows it, else `deviceTimeZone()`, else UTC. On a server, always pass the person's zone.
586
+ - `dayIn(zone, at?)` and `wallClock(zone, at?)`: what the calendar and the clock say in `zone`.
587
+ - `startOfDayIn(day, zone)` and `dayRangeIn(day, zone)`: the instants a day starts and ends.
588
+ Daylight-saving days are 23 or 25 hours long, and where midnight is skipped the day starts at the
589
+ first instant that exists.
590
+ - `addDays`, `daysBetween`, `weekdayOf`, `startOfWeek` and `isDay`: plain calendar arithmetic on
591
+ `YYYY-MM-DD` strings, with no zone involved. They refuse days that don't exist, such as `2026-02-30`.
592
+
593
+ In SQL, the same day is `(now() at time zone person_zone)::date`.
@@ -0,0 +1,52 @@
1
+ import { isDataWriteError } from './chunk-LG6TUBVM.js';
2
+ import { useQueryClient, useMutation } from '@tanstack/react-query';
3
+ import { useRef, useCallback } from 'react';
4
+
5
+ var WRITE_FAILED = "That didn't work. Try again.";
6
+ function useWrite(write, options = {}) {
7
+ const queryClient = useQueryClient();
8
+ const mutation = useMutation(writeMutation(queryClient, write, options));
9
+ const { mutateAsync } = mutation;
10
+ const inFlight = useRef(null);
11
+ const run = useCallback(
12
+ (args) => {
13
+ if (inFlight.current) return inFlight.current;
14
+ const p = mutateAsync(args).finally(() => {
15
+ if (inFlight.current === p) inFlight.current = null;
16
+ });
17
+ inFlight.current = p;
18
+ return p;
19
+ },
20
+ [mutateAsync]
21
+ );
22
+ const fire = useCallback((args) => run(args).catch(() => void 0), [run]);
23
+ const messageOf = options.messageOf ?? plainMessage;
24
+ return {
25
+ run,
26
+ fire,
27
+ pending: mutation.isPending,
28
+ error: mutation.error === null ? null : messageOf(mutation.error),
29
+ reset: mutation.reset
30
+ };
31
+ }
32
+ function writeMutation(queryClient, write, options) {
33
+ return {
34
+ mutationFn: write,
35
+ onSuccess: async (result, args) => {
36
+ try {
37
+ const keys = typeof options.invalidates === "function" ? options.invalidates(result, args) : options.invalidates;
38
+ await Promise.all((keys ?? []).map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })));
39
+ options.onSuccess?.(result, args);
40
+ } catch (error) {
41
+ console.error("[genesis-core] useWrite: a callback after the write failed:", error);
42
+ }
43
+ }
44
+ };
45
+ }
46
+ function plainMessage(error) {
47
+ return isDataWriteError(error) && error.message ? error.message : WRITE_FAILED;
48
+ }
49
+
50
+ export { WRITE_FAILED, useWrite, writeMutation };
51
+ //# sourceMappingURL=chunk-52T2EYXH.js.map
52
+ //# sourceMappingURL=chunk-52T2EYXH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-write.ts"],"names":[],"mappings":";;;;AAKO,IAAM,YAAA,GAAe;AAsCrB,SAAS,QAAA,CACd,KAAA,EACA,OAAA,GAAiC,EAAC,EACZ;AACtB,EAAA,MAAM,cAAc,cAAA,EAAe;AACnC,EAAA,MAAM,WAAW,WAAA,CAA2B,aAAA,CAAc,WAAA,EAAa,KAAA,EAAO,OAAO,CAAC,CAAA;AACtF,EAAA,MAAM,EAAE,aAAY,GAAI,QAAA;AAExB,EAAA,MAAM,QAAA,GAAW,OAA0B,IAAI,CAAA;AAC/C,EAAA,MAAM,GAAA,GAAM,WAAA;AAAA,IACV,CAAC,IAAA,KAAY;AACX,MAAA,IAAI,QAAA,CAAS,OAAA,EAAS,OAAO,QAAA,CAAS,OAAA;AACtC,MAAA,MAAM,CAAA,GAAI,WAAA,CAAY,IAAI,CAAA,CAAE,QAAQ,MAAM;AACxC,QAAA,IAAI,QAAA,CAAS,OAAA,KAAY,CAAA,EAAG,QAAA,CAAS,OAAA,GAAU,IAAA;AAAA,MACjD,CAAC,CAAA;AACD,MAAA,QAAA,CAAS,OAAA,GAAU,CAAA;AACnB,MAAA,OAAO,CAAA;AAAA,IACT,CAAA;AAAA,IACA,CAAC,WAAW;AAAA,GACd;AACA,EAAA,MAAM,IAAA,GAAO,WAAA,CAAY,CAAC,IAAA,KAAY,GAAA,CAAI,IAAI,CAAA,CAAE,KAAA,CAAM,MAAM,MAAS,CAAA,EAAG,CAAC,GAAG,CAAC,CAAA;AAC7E,EAAA,MAAM,SAAA,GAAY,QAAQ,SAAA,IAAa,YAAA;AACvC,EAAA,OAAO;AAAA,IACL,GAAA;AAAA,IACA,IAAA;AAAA,IACA,SAAS,QAAA,CAAS,SAAA;AAAA,IAClB,OAAO,QAAA,CAAS,KAAA,KAAU,OAAO,IAAA,GAAO,SAAA,CAAU,SAAS,KAAK,CAAA;AAAA,IAChE,OAAO,QAAA,CAAS;AAAA,GAClB;AACF;AAGO,SAAS,aAAA,CACd,WAAA,EACA,KAAA,EACA,OAAA,EACgC;AAChC,EAAA,OAAO;AAAA,IACL,UAAA,EAAY,KAAA;AAAA,IACZ,SAAA,EAAW,OAAO,MAAA,EAAQ,IAAA,KAAS;AAGjC,MAAA,IAAI;AACF,QAAA,MAAM,IAAA,GAAO,OAAO,OAAA,CAAQ,WAAA,KAAgB,UAAA,GAAa,QAAQ,WAAA,CAAY,MAAA,EAAQ,IAAI,CAAA,GAAI,OAAA,CAAQ,WAAA;AACrG,QAAA,MAAM,QAAQ,GAAA,CAAA,CAAK,IAAA,IAAQ,EAAC,EAAG,GAAA,CAAI,CAAC,QAAA,KAAa,WAAA,CAAY,iBAAA,CAAkB,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAA,EAAG,CAAC,CAAC,CAAA;AAC5G,QAAA,OAAA,CAAQ,SAAA,GAAY,QAAQ,IAAI,CAAA;AAAA,MAClC,SAAS,KAAA,EAAO;AACd,QAAA,OAAA,CAAQ,KAAA,CAAM,+DAA+D,KAAK,CAAA;AAAA,MACpF;AAAA,IACF;AAAA,GACF;AACF;AAEA,SAAS,aAAa,KAAA,EAAwB;AAC5C,EAAA,OAAO,iBAAiB,KAAK,CAAA,IAAK,KAAA,CAAM,OAAA,GAAU,MAAM,OAAA,GAAU,YAAA;AACpE","file":"chunk-52T2EYXH.js","sourcesContent":["import { useMutation, useQueryClient, type QueryClient, type MutationOptions } from \"@tanstack/react-query\";\nimport { useCallback, useRef } from \"react\";\nimport type { InvalidationKey } from \"../provider/types\";\nimport { isDataWriteError } from \"../schema/write-outcome\";\n\nexport const WRITE_FAILED = \"That didn't work. Try again.\";\n\nexport interface UseWriteOptions<A, R> {\n /** Keys made stale once the write succeeds (matched by prefix), or a function of its result. */\n invalidates?: InvalidationKey[] | ((result: R, args: A) => InvalidationKey[]);\n onSuccess?: (result: R, args: A) => void;\n /** The words shown for a failure. Default: a write error's own plain message. */\n messageOf?: (error: unknown) => string;\n}\n\nexport interface UseWriteReturn<A, R> {\n /**\n * Runs the write. Resolves with its result; rejects with the error (also\n * shown in `error`). While one runs, another call returns the same promise.\n */\n run: (args: A) => Promise<R>;\n /** Runs the write and never rejects: resolves with the result, or undefined when it failed. */\n fire: (args: A) => Promise<R | undefined>;\n pending: boolean;\n /** Why the last run failed, in plain words; null after a success or `reset`. */\n error: string | null;\n reset: () => void;\n}\n\n/**\n * A one-off write that isn't a resource page's create, update or remove: a\n * database function, `createOrAdopt`, several writes in a row. It invalidates\n * what it made stale and reports failures in plain words.\n *\n * ```ts\n * const sendLesson = useWrite(\n * (lessonId: string) => callRpc(supabase, \"send_lesson\", { lesson_id: lessonId }),\n * { invalidates: [[\"tasks\"], lessonKeys.all] },\n * );\n * <Button onClick={() => sendLesson.fire(id)} disabled={sendLesson.pending}>Send</Button>\n * {sendLesson.error && <InlineError>{sendLesson.error}</InlineError>}\n * ```\n */\nexport function useWrite<A = void, R = unknown>(\n write: (args: A) => Promise<R>,\n options: UseWriteOptions<A, R> = {},\n): UseWriteReturn<A, R> {\n const queryClient = useQueryClient();\n const mutation = useMutation<R, unknown, A>(writeMutation(queryClient, write, options));\n const { mutateAsync } = mutation;\n // One write at a time: a second click while it runs gets the same promise, not a second write.\n const inFlight = useRef<Promise<R> | null>(null);\n const run = useCallback(\n (args: A) => {\n if (inFlight.current) return inFlight.current;\n const p = mutateAsync(args).finally(() => {\n if (inFlight.current === p) inFlight.current = null;\n });\n inFlight.current = p;\n return p;\n },\n [mutateAsync],\n );\n const fire = useCallback((args: A) => run(args).catch(() => undefined), [run]);\n const messageOf = options.messageOf ?? plainMessage;\n return {\n run,\n fire,\n pending: mutation.isPending,\n error: mutation.error === null ? null : messageOf(mutation.error),\n reset: mutation.reset,\n };\n}\n\n/** useWrite's mutation: run the write, then invalidate and call `onSuccess`. */\nexport function writeMutation<A, R>(\n queryClient: QueryClient,\n write: (args: A) => Promise<R>,\n options: UseWriteOptions<A, R>,\n): MutationOptions<R, unknown, A> {\n return {\n mutationFn: write,\n onSuccess: async (result, args) => {\n // The write committed: a failing callback is logged, never reported as a failed write\n // (the person would make it again).\n try {\n const keys = typeof options.invalidates === \"function\" ? options.invalidates(result, args) : options.invalidates;\n await Promise.all((keys ?? []).map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })));\n options.onSuccess?.(result, args);\n } catch (error) {\n console.error(\"[genesis-core] useWrite: a callback after the write failed:\", error);\n }\n },\n };\n}\n\nfunction plainMessage(error: unknown): string {\n return isDataWriteError(error) && error.message ? error.message : WRITE_FAILED;\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { flushPendingWrites, dropPendingWrites } from './chunk-S2ZXUNXB.js';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useState, Fragment as Fragment$1 } from 'react';
4
+ import { jsx, Fragment } from 'react/jsx-runtime';
5
+
6
+ async function clearViewerCache(queryClient) {
7
+ await flushPendingWrites(queryClient);
8
+ await queryClient.cancelQueries();
9
+ queryClient.clear();
10
+ }
11
+ function ViewerScope({ viewer, persistedViewer, fallback = null, children }) {
12
+ const queryClient = useQueryClient();
13
+ const [mounted, setMounted] = useState(void 0);
14
+ if (viewer === void 0) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
15
+ if (viewer !== mounted) {
16
+ const keep = mounted === void 0 && persistedViewer !== void 0 && persistedViewer === viewer;
17
+ if (!keep) {
18
+ dropPendingWrites(queryClient);
19
+ void queryClient.cancelQueries();
20
+ queryClient.clear();
21
+ }
22
+ setMounted(viewer);
23
+ return /* @__PURE__ */ jsx(Fragment, { children: fallback });
24
+ }
25
+ return /* @__PURE__ */ jsx(Fragment$1, { children }, viewer ?? "signed-out");
26
+ }
27
+
28
+ export { ViewerScope, clearViewerCache };
29
+ //# sourceMappingURL=chunk-5ORRAXIV.js.map
30
+ //# sourceMappingURL=chunk-5ORRAXIV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/viewer-scope.tsx"],"names":["Fragment"],"mappings":";;;;;AAUA,eAAsB,iBAAiB,WAAA,EAAyC;AAC9E,EAAA,MAAM,mBAAmB,WAAW,CAAA;AACpC,EAAA,MAAM,YAAY,aAAA,EAAc;AAChC,EAAA,WAAA,CAAY,KAAA,EAAM;AACpB;AA6BO,SAAS,YAAY,EAAE,MAAA,EAAQ,iBAAiB,QAAA,GAAW,IAAA,EAAM,UAAS,EAAqB;AACpG,EAAA,MAAM,cAAc,cAAA,EAAe;AAEnC,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAI,SAAoC,MAAS,CAAA;AAC3E,EAAA,IAAI,WAAW,MAAA,EAAW,uBAAO,GAAA,CAAAA,QAAAA,EAAA,EAAG,QAAA,EAAA,QAAA,EAAS,CAAA;AAC7C,EAAA,IAAI,WAAW,OAAA,EAAS;AACtB,IAAA,MAAM,IAAA,GAAO,OAAA,KAAY,MAAA,IAAa,eAAA,KAAoB,UAAa,eAAA,KAAoB,MAAA;AAG3F,IAAA,IAAI,CAAC,IAAA,EAAM;AAIT,MAAA,iBAAA,CAAkB,WAAW,CAAA;AAC7B,MAAA,KAAK,YAAY,aAAA,EAAc;AAC/B,MAAA,WAAA,CAAY,KAAA,EAAM;AAAA,IACpB;AACA,IAAA,UAAA,CAAW,MAAM,CAAA;AACjB,IAAA,uBAAO,GAAA,CAAAA,QAAAA,EAAA,EAAG,QAAA,EAAA,QAAA,EAAS,CAAA;AAAA,EACrB;AACA,EAAA,uBAAO,GAAA,CAACA,UAAA,EAAA,EAAuC,QAAA,EAAA,EAAzB,MAAA,IAAU,YAAwB,CAAA;AAC1D","file":"chunk-5ORRAXIV.js","sourcesContent":["import { useQueryClient, type QueryClient } from \"@tanstack/react-query\";\nimport { Fragment, useState, type ReactNode } from \"react\";\nimport { dropPendingWrites, flushPendingWrites } from \"./mutation-mode\";\n\n/**\n * Empties the query cache for a new viewer: sends the old viewer's waiting\n * undoable writes (the session is still theirs), cancels reads in flight,\n * then removes every query. Call it in the sign-in and sign-out actions,\n * before the session changes.\n */\nexport async function clearViewerCache(queryClient: QueryClient): Promise<void> {\n await flushPendingWrites(queryClient);\n await queryClient.cancelQueries();\n queryClient.clear();\n}\n\nexport interface ViewerScopeProps {\n /** The signed-in viewer's id, null when signed out, undefined while the session loads. */\n viewer: string | null | undefined;\n /**\n * The viewer a persisted cache was saved for. When the first known viewer\n * is this one, the restored cache is kept; otherwise it's emptied.\n */\n persistedViewer?: string | null;\n /** Shown while the session loads (and for the one render a change takes). */\n fallback?: ReactNode;\n children: ReactNode;\n}\n\n/**\n * Keeps one viewer's cached data from ever showing to the next. Children\n * render only once the viewer is known, under a key of that viewer, so a\n * sign-out or a new person signing in unmounts every screen and its queries\n * before the cache is emptied and the new viewer's screens mount fresh. The\n * first known viewer empties a restored cache too, unless it's the\n * `persistedViewer` it was saved for.\n *\n * ```tsx\n * <ViewerScope viewer={session === undefined ? undefined : (session?.user.id ?? null)}>\n * <App />\n * </ViewerScope>\n * ```\n */\nexport function ViewerScope({ viewer, persistedViewer, fallback = null, children }: ViewerScopeProps) {\n const queryClient = useQueryClient();\n // The viewer the children were last mounted for; undefined before the first.\n const [mounted, setMounted] = useState<string | null | undefined>(undefined);\n if (viewer === undefined) return <>{fallback}</>;\n if (viewer !== mounted) {\n const keep = mounted === undefined && persistedViewer !== undefined && persistedViewer === viewer;\n // Nothing for the new viewer is mounted yet, so emptying now can't strand\n // a fetch; the old viewer's screens unmount with this render.\n if (!keep) {\n // The session already belongs to the new viewer: the old one's unsent\n // writes must never go out under it. (clearViewerCache, run before the\n // session changes, sends them instead.)\n dropPendingWrites(queryClient);\n void queryClient.cancelQueries();\n queryClient.clear();\n }\n setMounted(viewer);\n return <>{fallback}</>;\n }\n return <Fragment key={viewer ?? \"signed-out\"}>{children}</Fragment>;\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { resourceKeys } from './chunk-RCAP3Q4T.js';
1
2
  import { useLiveInvalidation } from './chunk-KXVHDK3Q.js';
2
3
  import { useDataProvider } from './chunk-GGSXSI6Z.js';
3
4
  import { useQuery } from '@tanstack/react-query';
@@ -11,7 +12,7 @@ function useOne(resource, id, options = {}) {
11
12
  live,
12
13
  isEnabled && id != null,
13
14
  id != null ? { id } : {},
14
- [resource, "detail", id],
15
+ resourceKeys(resource).detailsOf(id),
15
16
  (event) => event.type === "changed" || !("id" in event) || event.id == null || String(event.id) === String(id)
16
17
  );
17
18
  return useQuery({
@@ -23,7 +24,7 @@ function useOne(resource, id, options = {}) {
23
24
  // — no error, no refetch, just absent data. `useResource`'s list key has
24
25
  // always included `select` (it keys on the whole `params` object); this
25
26
  // was the one place it did not.
26
- queryKey: [resource, "detail", id, select],
27
+ queryKey: resourceKeys(resource).detail(id, select),
27
28
  queryFn: async () => {
28
29
  if (id == null) throw new Error("Missing id");
29
30
  const result = await provider.getOne(resource, { id, select });
@@ -34,5 +35,5 @@ function useOne(resource, id, options = {}) {
34
35
  }
35
36
 
36
37
  export { useOne };
37
- //# sourceMappingURL=chunk-ZKMAU6NN.js.map
38
- //# sourceMappingURL=chunk-ZKMAU6NN.js.map
38
+ //# sourceMappingURL=chunk-BGTXN2E6.js.map
39
+ //# sourceMappingURL=chunk-BGTXN2E6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-one.ts"],"names":[],"mappings":";;;;;AAiCO,SAAS,MAAA,CACd,QAAA,EACA,EAAA,EACA,OAAA,GAAyB,EAAC,EACP;AACnB,EAAA,MAAM,WAAW,eAAA,EAAgB;AAEjC,EAAA,MAAM,EAAE,MAAA,EAAQ,OAAA,EAAS,IAAA,EAAK,GAAI,OAAA;AAClC,EAAA,MAAM,SAAA,GAAY,OAAA,IAAW,CAAC,CAAC,EAAA;AAG/B,EAAA,mBAAA;AAAA,IACE,QAAA;AAAA,IACA,IAAA;AAAA,IACA,aAAa,EAAA,IAAM,IAAA;AAAA,IACnB,EAAA,IAAM,IAAA,GAAO,EAAE,EAAA,KAAO,EAAC;AAAA,IACvB,YAAA,CAAa,QAAQ,CAAA,CAAE,SAAA,CAAU,EAAE,CAAA;AAAA,IACnC,CAAC,KAAA,KAAU,KAAA,CAAM,IAAA,KAAS,SAAA,IAAa,EAAE,IAAA,IAAQ,KAAA,CAAA,IAAU,KAAA,CAAM,EAAA,IAAM,QAAQ,MAAA,CAAO,KAAA,CAAM,EAAE,CAAA,KAAM,OAAO,EAAE;AAAA,GAC/G;AAEA,EAAA,OAAO,QAAA,CAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASjB,UAAU,YAAA,CAAa,QAAQ,CAAA,CAAE,MAAA,CAAO,IAAI,MAAM,CAAA;AAAA,IAClD,SAAS,YAAY;AACnB,MAAA,IAAI,EAAA,IAAM,IAAA,EAAM,MAAM,IAAI,MAAM,YAAY,CAAA;AAC5C,MAAA,MAAM,MAAA,GAAS,MAAM,QAAA,CAAS,MAAA,CAAU,UAAU,EAAE,EAAA,EAAI,QAAQ,CAAA;AAChE,MAAA,OAAO,MAAA,CAAO,IAAA;AAAA,IAChB,CAAA;AAAA,IACA,OAAA,EAAS;AAAA,GACV,CAAA;AACH","file":"chunk-BGTXN2E6.js","sourcesContent":["import { useQuery, type UseQueryResult } from \"@tanstack/react-query\";\nimport { useDataProvider } from \"../provider/context\";\nimport type { BaseRecord, Identifier } from \"../provider/types\";\nimport { useLiveInvalidation, type LiveOptions } from \"./use-live\";\nimport { resourceKeys } from \"../query-keys\";\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport interface UseOneOptions {\n /** Supabase select/join syntax, e.g. \"*, author:profiles(name)\". */\n select?: string;\n /** Defaults to `!!id` — won't fetch when id is falsy. */\n enabled?: boolean;\n /**\n * Keep the record current while mounted: refetch it when the provider\n * reports a change to it (`DataProvider.subscribe`). See\n * `UseResourceOptions.live`. Off by default.\n */\n live?: boolean | LiveOptions;\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Fetch a single record by id, powered by TanStack Query.\n *\n * Returns the full `UseQueryResult<T>` with the data unwrapped from the\n * provider's `GetOneResult` wrapper.\n */\nexport function useOne<T extends BaseRecord = BaseRecord>(\n resource: string,\n id: Identifier | undefined | null,\n options: UseOneOptions = {},\n): UseQueryResult<T> {\n const provider = useDataProvider();\n\n const { select, enabled, live } = options;\n const isEnabled = enabled ?? !!id;\n\n // Only this record's events; `changed` (events may have been missed) too.\n useLiveInvalidation(\n resource,\n live,\n isEnabled && id != null,\n id != null ? { id } : {},\n resourceKeys(resource).detailsOf(id),\n (event) => event.type === \"changed\" || !(\"id\" in event) || event.id == null || String(event.id) === String(id),\n );\n\n return useQuery<T>({\n // `select` is part of the key because it is part of the REQUEST: two pages\n // asking for the same record with different projections get different\n // rows back, and keying only on the id makes them share one cache entry.\n // Whichever mounted first wins for the whole staleTime window, and the\n // other silently renders a record missing the joined fields it asked for\n // — no error, no refetch, just absent data. `useResource`'s list key has\n // always included `select` (it keys on the whole `params` object); this\n // was the one place it did not.\n queryKey: resourceKeys(resource).detail(id, select),\n queryFn: async () => {\n if (id == null) throw new Error(\"Missing id\");\n const result = await provider.getOne<T>(resource, { id, select });\n return result.data;\n },\n enabled: isEnabled,\n });\n}\n"]}
@@ -0,0 +1,38 @@
1
+ import { DraftController } from './chunk-HBAPYA5K.js';
2
+ import { useRef, useState, useSyncExternalStore, useEffect } from 'react';
3
+
4
+ function useDraft(options) {
5
+ const latest = useRef(options);
6
+ latest.current = options;
7
+ const [draft] = useState(() => new DraftController(() => latest.current));
8
+ const state = useSyncExternalStore(draft.subscribe, draft.getState, draft.getState);
9
+ useEffect(() => {
10
+ draft.reseed(options.seed, options.revision);
11
+ }, [draft, options.revision]);
12
+ useEffect(() => {
13
+ draft.sync();
14
+ }, [draft, options.enabled]);
15
+ useEffect(() => {
16
+ if (typeof window === "undefined") return;
17
+ const beforeUnload = (event) => {
18
+ if (!draft.pending()) return;
19
+ void draft.flush({ keepalive: true });
20
+ event.preventDefault();
21
+ };
22
+ const pageHide = () => {
23
+ if (draft.pending()) void draft.flush({ keepalive: true });
24
+ };
25
+ window.addEventListener("beforeunload", beforeUnload);
26
+ window.addEventListener("pagehide", pageHide);
27
+ return () => {
28
+ window.removeEventListener("beforeunload", beforeUnload);
29
+ window.removeEventListener("pagehide", pageHide);
30
+ if (draft.pending()) void draft.flush();
31
+ };
32
+ }, [draft]);
33
+ return { ...state, set: draft.set, flush: draft.flush, commit: draft.commit };
34
+ }
35
+
36
+ export { useDraft };
37
+ //# sourceMappingURL=chunk-DF2UWEEB.js.map
38
+ //# sourceMappingURL=chunk-DF2UWEEB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-draft.ts"],"names":[],"mappings":";;;AAqCO,SAAS,SAAkC,OAAA,EAAmD;AACnG,EAAA,MAAM,MAAA,GAAS,OAAO,OAAO,CAAA;AAC7B,EAAA,MAAA,CAAO,OAAA,GAAU,OAAA;AAEjB,EAAA,MAAM,CAAC,KAAK,CAAA,GAAI,QAAA,CAAS,MAAM,IAAI,eAAA,CAAsB,MAAM,MAAA,CAAO,OAAO,CAAC,CAAA;AAC9E,EAAA,MAAM,QAAQ,oBAAA,CAAqB,KAAA,CAAM,WAAW,KAAA,CAAM,QAAA,EAAU,MAAM,QAAQ,CAAA;AAElF,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,KAAA,CAAM,MAAA,CAAO,OAAA,CAAQ,IAAA,EAAM,OAAA,CAAQ,QAAQ,CAAA;AAAA,EAG7C,CAAA,EAAG,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAE5B,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,KAAA,CAAM,IAAA,EAAK;AAAA,EACb,CAAA,EAAG,CAAC,KAAA,EAAO,OAAA,CAAQ,OAAO,CAAC,CAAA;AAE3B,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACnC,IAAA,MAAM,YAAA,GAAe,CAAC,KAAA,KAA6B;AACjD,MAAA,IAAI,CAAC,KAAA,CAAM,OAAA,EAAQ,EAAG;AACtB,MAAA,KAAK,KAAA,CAAM,KAAA,CAAM,EAAE,SAAA,EAAW,MAAM,CAAA;AACpC,MAAA,KAAA,CAAM,cAAA,EAAe;AAAA,IACvB,CAAA;AACA,IAAA,MAAM,WAAW,MAAM;AACrB,MAAA,IAAI,KAAA,CAAM,SAAQ,EAAG,KAAK,MAAM,KAAA,CAAM,EAAE,SAAA,EAAW,IAAA,EAAM,CAAA;AAAA,IAC3D,CAAA;AACA,IAAA,MAAA,CAAO,gBAAA,CAAiB,gBAAgB,YAAY,CAAA;AACpD,IAAA,MAAA,CAAO,gBAAA,CAAiB,YAAY,QAAQ,CAAA;AAC5C,IAAA,OAAO,MAAM;AACX,MAAA,MAAA,CAAO,mBAAA,CAAoB,gBAAgB,YAAY,CAAA;AACvD,MAAA,MAAA,CAAO,mBAAA,CAAoB,YAAY,QAAQ,CAAA;AAE/C,MAAA,IAAI,KAAA,CAAM,OAAA,EAAQ,EAAG,KAAK,MAAM,KAAA,EAAM;AAAA,IACxC,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAEV,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,GAAA,EAAK,KAAA,CAAM,GAAA,EAAK,KAAA,EAAO,KAAA,CAAM,KAAA,EAAO,MAAA,EAAQ,KAAA,CAAM,MAAA,EAAO;AAC9E","file":"chunk-DF2UWEEB.js","sourcesContent":["import { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n DraftController,\n type DraftOptions,\n type DraftState,\n type DraftValue,\n} from \"./draft-controller\";\n\nexport interface UseDraftReturn<V extends DraftValue, R> extends DraftState<V, R> {\n /** The editor's new value; saved a moment after the last change. */\n set: (value: V) => void;\n /** Save now. Never rejects; a failure shows in `status` and `error`. */\n flush: () => Promise<void>;\n /** The final write (a submit); see `DraftController.commit`. */\n commit: DraftController<V, R>[\"commit\"];\n}\n\n/**\n * Autosave one draft row: saved a moment after each change, one write at a\n * time, added on the first save that has something in it, merged into a row\n * another tab started (`adopt`), and started again when the server's copy wins\n * (`revision`). Leaving the page saves what's pending (and warns while it\n * can't), and `commit` makes the final write. The app supplies the writes.\n *\n * ```tsx\n * const draft = useDraft({\n * seed: { row: entry, value: entry?.answers ?? {} },\n * revision: `${entry?.id}:${entry?.status}`,\n * enabled: entry?.status !== \"sent\",\n * create: (answers) => api.addEntry(answers),\n * update: (row, answers) => api.saveEntry(row.id, answers),\n * toValue: (row) => row.answers,\n * onSaved: (row) => queryClient.setQueryData([\"entry\", row.id], row),\n * });\n * <Editor key={draft.key} value={draft.value} onChange={draft.set} />\n * ```\n */\nexport function useDraft<V extends DraftValue, R>(options: DraftOptions<V, R>): UseDraftReturn<V, R> {\n const latest = useRef(options);\n latest.current = options;\n // Created once; reads the latest options on every use, so callbacks are never stale.\n const [draft] = useState(() => new DraftController<V, R>(() => latest.current));\n const state = useSyncExternalStore(draft.subscribe, draft.getState, draft.getState);\n\n useEffect(() => {\n draft.reseed(options.seed, options.revision);\n // Only a new revision starts the draft again; a new seed object alone doesn't.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [draft, options.revision]);\n\n useEffect(() => {\n draft.sync();\n }, [draft, options.enabled]);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const beforeUnload = (event: BeforeUnloadEvent) => {\n if (!draft.pending()) return;\n void draft.flush({ keepalive: true });\n event.preventDefault();\n };\n const pageHide = () => {\n if (draft.pending()) void draft.flush({ keepalive: true });\n };\n window.addEventListener(\"beforeunload\", beforeUnload);\n window.addEventListener(\"pagehide\", pageHide);\n return () => {\n window.removeEventListener(\"beforeunload\", beforeUnload);\n window.removeEventListener(\"pagehide\", pageHide);\n // Leaving the page within the app: save what's pending.\n if (draft.pending()) void draft.flush();\n };\n }, [draft]);\n\n return { ...state, set: draft.set, flush: draft.flush, commit: draft.commit };\n}\n"]}
@@ -1,6 +1,6 @@
1
1
  import { DataReadError, readFailureMessage } from './chunk-HUNGCTRA.js';
2
- import { isDataWriteError, DataWriteError } from './chunk-LG6TUBVM.js';
3
2
  import { titleCase } from './chunk-RYEXWQFD.js';
3
+ import { isDataWriteError, DataWriteError } from './chunk-LG6TUBVM.js';
4
4
 
5
5
  // src/provider/postgrest-errors.ts
6
6
  function readOutcomeFromCode(code) {
@@ -252,6 +252,35 @@ function createSupabaseProvider(client, options = {}) {
252
252
  await reportReadError(error, resource, operation);
253
253
  }
254
254
  const from = (resource) => client.from(resource);
255
+ function readName(resource) {
256
+ const source = configFor(resource).source;
257
+ return typeof source === "string" ? source : source ? source.rpc : resource;
258
+ }
259
+ async function tableValues(resource, values, id) {
260
+ const config = configFor(resource);
261
+ if (config.writeColumns) {
262
+ const allowed = new Set(config.writeColumns);
263
+ return Object.fromEntries(Object.entries(values).filter(([key]) => allowed.has(key)));
264
+ }
265
+ if (!config.source || id === void 0) return values;
266
+ const row = await readBack(resource, id);
267
+ if (!row) return values;
268
+ return Object.fromEntries(Object.entries(values).filter(([key]) => key in row));
269
+ }
270
+ function read(resource, columns, count) {
271
+ const config = configFor(resource);
272
+ const cols = columns ?? config.select ?? "*";
273
+ const source = config.source;
274
+ if (source && typeof source === "object") {
275
+ if (!client.rpc) {
276
+ throw new DataReadError("error", `[SupabaseProvider] "${resource}" reads from ${source.rpc}(), but the client has no rpc.`);
277
+ }
278
+ const builder = client.rpc(source.rpc, source.args?.() ?? {}, count ? { count } : void 0);
279
+ return builder.select(cols);
280
+ }
281
+ const table = client.from(source ?? resource);
282
+ return count ? table.select(cols, { count }) : table.select(cols);
283
+ }
255
284
  const configFor = (resource) => options.resources?.[resource] ?? {};
256
285
  const nounFor = (resource) => configFor(resource).noun ?? "record";
257
286
  function realtimeEnabled(resource) {
@@ -366,10 +395,7 @@ function createSupabaseProvider(client, options = {}) {
366
395
  return refuseCursor(resource, "the cursor was made for a different sort; start again from the first page (cursor: null).");
367
396
  }
368
397
  }
369
- let query = from(resource).select(
370
- params.select ?? "*",
371
- ...cursor ? [] : [{ count: "exact" }]
372
- );
398
+ let query = read(resource, params.select, cursor ? void 0 : "exact");
373
399
  query = applyFilters(query, params.filters);
374
400
  const search = searchClauses(resource, params);
375
401
  const keyset = after ? keysetClauses(after) : "";
@@ -430,10 +456,7 @@ function createSupabaseProvider(client, options = {}) {
430
456
  return getCursorPage(resource, params, configuredKey === void 0 ? "id" : configuredKey);
431
457
  }
432
458
  const run2 = (key) => {
433
- let query = client.from(resource).select(
434
- params.select ?? "*",
435
- { count: "exact" }
436
- );
459
+ let query = read(resource, params.select, "exact");
437
460
  query = applyFilters(query, params.filters);
438
461
  const clauses = searchClauses(resource, params);
439
462
  if (clauses) {
@@ -457,7 +480,7 @@ function createSupabaseProvider(client, options = {}) {
457
480
  };
458
481
  const guessKey = configuredKey === void 0 && !keylessResources.has(resource);
459
482
  let result = await run2(configuredKey ?? (keylessResources.has(resource) ? false : "id"));
460
- if (guessKey && isMissingColumn(result.error, resource, "id")) {
483
+ if (guessKey && isMissingColumn(result.error, readName(resource), "id")) {
461
484
  keylessResources.add(resource);
462
485
  if (process.env.NODE_ENV !== "production") {
463
486
  console.warn(
@@ -470,13 +493,13 @@ function createSupabaseProvider(client, options = {}) {
470
493
  return { data: result.data ?? [], total: result.count ?? 0 };
471
494
  },
472
495
  async getOne(resource, params) {
473
- const result = await client.from(resource).select(params.select ?? "*").eq("id", params.id).single();
496
+ const result = await read(resource, params.select).eq("id", params.id).single();
474
497
  await throwIfReadError(result, resource, "getOne");
475
498
  return { data: result.data };
476
499
  },
477
500
  async getMany(resource, params) {
478
501
  if (params.ids.length === 0) return { data: [] };
479
- const result = await client.from(resource).select("*").in("id", params.ids);
502
+ const result = await read(resource, void 0).in("id", params.ids);
480
503
  await throwIfReadError(result, resource, "getMany");
481
504
  return { data: result.data ?? [] };
482
505
  },
@@ -556,10 +579,10 @@ function createSupabaseProvider(client, options = {}) {
556
579
  };
557
580
  },
558
581
  async create(resource, params) {
559
- const row = await prepare(resource, params.data, {
582
+ const row = await tableValues(
560
583
  resource,
561
- action: "create"
562
- });
584
+ await prepare(resource, params.data, { resource, action: "create" })
585
+ );
563
586
  const override = configFor(resource).create;
564
587
  let record;
565
588
  if (override) {
@@ -588,11 +611,11 @@ function createSupabaseProvider(client, options = {}) {
588
611
  },
589
612
  async update(resource, params) {
590
613
  const { id } = params;
591
- const row = await prepare(resource, params.data, {
614
+ const row = await tableValues(
592
615
  resource,
593
- action: "update",
616
+ await prepare(resource, params.data, { resource, action: "update", id }),
594
617
  id
595
- });
618
+ );
596
619
  const override = configFor(resource).update;
597
620
  let record;
598
621
  if (override) {
@@ -665,6 +688,60 @@ function createSupabaseProvider(client, options = {}) {
665
688
  return provider;
666
689
  }
667
690
 
668
- export { createSupabaseProvider, fieldErrorsFromPostgrest, readOutcomeFromCode, toDataWriteError, writeOutcomeFromCode };
669
- //# sourceMappingURL=chunk-XKLYDAT5.js.map
670
- //# sourceMappingURL=chunk-XKLYDAT5.js.map
691
+ // src/provider/write-helpers.ts
692
+ var DEFAULT_RPC_MESSAGES = {
693
+ "42501": "You can't do that.",
694
+ "22023": "That request isn't valid.",
695
+ "23505": "That already exists.",
696
+ "28000": "Sign in to continue.",
697
+ PGRST202: "That action isn't available.",
698
+ PGRST301: "Sign in to continue."
699
+ };
700
+ var RPC_FAILED = "That didn't work. Try again.";
701
+ async function callRpc(client, fn, args = {}, options = {}) {
702
+ let result;
703
+ try {
704
+ result = await client.rpc(fn, args);
705
+ } catch (error) {
706
+ if (isDataWriteError(error)) throw error;
707
+ throw new DataWriteError("error", RPC_FAILED, { cause: error });
708
+ }
709
+ if (!result || typeof result !== "object") {
710
+ throw new DataWriteError("error", RPC_FAILED, { cause: new Error(`rpc ${fn} returned ${String(result)}`) });
711
+ }
712
+ if (!result.error) return result.data;
713
+ const mapped = toDataWriteError(result.error);
714
+ const code = mapped.code;
715
+ const custom = code !== void 0 ? options.messages?.[code] : void 0;
716
+ const raw = result.error.message;
717
+ const guardWords = typeof raw === "string" && raw.trim() ? raw : void 0;
718
+ const message = custom ?? (code === "P0001" ? guardWords : void 0) ?? (mapped.fields ? mapped.message : void 0) ?? (code !== void 0 ? DEFAULT_RPC_MESSAGES[code] : void 0) ?? RPC_FAILED;
719
+ throw new DataWriteError(mapped.outcome, message, { code, fields: mapped.fields, cause: result.error });
720
+ }
721
+ async function createOrAdopt(provider, resource, data, options) {
722
+ if (options.key.length === 0) throw new Error(`[genesis-core] createOrAdopt("${resource}") needs a key`);
723
+ try {
724
+ return { row: (await provider.create(resource, { data })).data, adopted: false };
725
+ } catch (error) {
726
+ if (!isDataWriteError(error) || error.outcome !== "duplicate") throw error;
727
+ if (error.fields && Object.keys(error.fields).some((field) => !options.key.includes(field))) throw error;
728
+ const filters = options.key.map((field) => {
729
+ const value = data[field];
730
+ if (value === null || value === void 0) return { field, operator: "is_null" };
731
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
732
+ throw new Error(`[genesis-core] createOrAdopt("${resource}") key field ${field} must be a string, number or boolean`);
733
+ }
734
+ return { field, operator: "eq", value };
735
+ });
736
+ const { data: rows } = await provider.getList(resource, { filters, pagination: { page: 1, perPage: 2 } });
737
+ if (rows.length !== 1) throw error;
738
+ const existing = rows[0];
739
+ const changes = options.merge?.(existing);
740
+ if (!changes || Object.keys(changes).length === 0) return { row: existing, adopted: true };
741
+ return { row: (await provider.update(resource, { id: existing.id, data: changes })).data, adopted: true };
742
+ }
743
+ }
744
+
745
+ export { DEFAULT_RPC_MESSAGES, RPC_FAILED, callRpc, createOrAdopt, createSupabaseProvider, fieldErrorsFromPostgrest, readOutcomeFromCode, toDataWriteError, writeOutcomeFromCode };
746
+ //# sourceMappingURL=chunk-EF5BKT4B.js.map
747
+ //# sourceMappingURL=chunk-EF5BKT4B.js.map