@marktiderman/genesis-core 1.7.0 → 1.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.
- package/README.md +182 -0
- package/dist/chunk-52T2EYXH.js +52 -0
- package/dist/chunk-52T2EYXH.js.map +1 -0
- package/dist/chunk-5ORRAXIV.js +30 -0
- package/dist/chunk-5ORRAXIV.js.map +1 -0
- package/dist/{chunk-ZKMAU6NN.js → chunk-BGTXN2E6.js} +5 -4
- package/dist/chunk-BGTXN2E6.js.map +1 -0
- package/dist/chunk-DF2UWEEB.js +38 -0
- package/dist/chunk-DF2UWEEB.js.map +1 -0
- package/dist/{chunk-Y475Z7BM.js → chunk-EF5BKT4B.js} +99 -21
- package/dist/chunk-EF5BKT4B.js.map +1 -0
- package/dist/chunk-HBAPYA5K.js +257 -0
- package/dist/chunk-HBAPYA5K.js.map +1 -0
- package/dist/{chunk-5NHR6ZXS.js → chunk-LG6TUBVM.js} +2 -2
- package/dist/chunk-LG6TUBVM.js.map +1 -0
- package/dist/{chunk-QFR6YJ3R.js → chunk-NMFABW4Y.js} +6 -5
- package/dist/chunk-NMFABW4Y.js.map +1 -0
- package/dist/{chunk-ADD2RIGA.js → chunk-NZPJI7FC.js} +4 -3
- package/dist/chunk-NZPJI7FC.js.map +1 -0
- package/dist/{chunk-Y3SCYMNN.js → chunk-OSPLIKSH.js} +7 -7
- package/dist/{chunk-Y3SCYMNN.js.map → chunk-OSPLIKSH.js.map} +1 -1
- package/dist/chunk-RCAP3Q4T.js +75 -0
- package/dist/chunk-RCAP3Q4T.js.map +1 -0
- package/dist/{chunk-OBUBP4BY.js → chunk-S2ZXUNXB.js} +30 -6
- package/dist/chunk-S2ZXUNXB.js.map +1 -0
- package/dist/dates.d.ts +84 -0
- package/dist/dates.js +117 -0
- package/dist/dates.js.map +1 -0
- package/dist/hooks/draft-controller.d.ts +158 -0
- package/dist/hooks/draft-controller.js +4 -0
- package/dist/hooks/draft-controller.js.map +1 -0
- package/dist/hooks/index.d.ts +45 -3
- package/dist/hooks/index.js +15 -11
- package/dist/hooks/mutation-mode.d.ts +14 -2
- package/dist/hooks/mutation-mode.js +2 -1
- package/dist/hooks/related-keys.d.ts +1 -1
- package/dist/hooks/use-draft.d.ts +33 -0
- package/dist/hooks/use-draft.js +5 -0
- package/dist/hooks/use-draft.js.map +1 -0
- package/dist/hooks/use-infinite-resource.d.ts +1 -1
- package/dist/hooks/use-infinite-resource.js +2 -1
- package/dist/hooks/use-live.d.ts +1 -1
- package/dist/hooks/use-one.d.ts +1 -1
- package/dist/hooks/use-one.js +2 -1
- package/dist/hooks/use-resource-form.d.ts +1 -1
- package/dist/hooks/use-resource-form.js +8 -7
- package/dist/hooks/use-resource.d.ts +1 -1
- package/dist/hooks/use-resource.js +5 -4
- package/dist/hooks/use-write.d.ts +45 -0
- package/dist/hooks/use-write.js +4 -0
- package/dist/hooks/use-write.js.map +1 -0
- package/dist/index.d.ts +77 -5
- package/dist/index.js +16 -12
- package/dist/provider/index.d.ts +87 -8
- package/dist/provider/index.js +3 -3
- package/dist/{read-outcome-C_9HbXvb.d.ts → read-outcome-B6nACXZ7.d.ts} +4 -1
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/{types-CS9UpxQS.d.ts → types-B9DDfvWa.d.ts} +1 -1
- package/package.json +11 -2
- package/dist/chunk-5NHR6ZXS.js.map +0 -1
- package/dist/chunk-ADD2RIGA.js.map +0 -1
- package/dist/chunk-OBUBP4BY.js.map +0 -1
- package/dist/chunk-QFR6YJ3R.js.map +0 -1
- package/dist/chunk-V3GDAELU.js +0 -3
- package/dist/chunk-V3GDAELU.js.map +0 -1
- package/dist/chunk-Y475Z7BM.js.map +0 -1
- 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
|
|
@@ -117,6 +150,7 @@ try {
|
|
|
117
150
|
case "forbidden": /* hide the control, say why */ break;
|
|
118
151
|
case "gone": /* close the record, refresh the list */ break;
|
|
119
152
|
case "invalid": /* show error.message by the form */ break;
|
|
153
|
+
case "duplicate": /* adopt the existing row, or show error.fields by the form */ break;
|
|
120
154
|
case "unauthenticated": /* send to sign-in */ break;
|
|
121
155
|
default: /* show error.message */
|
|
122
156
|
}
|
|
@@ -354,3 +388,151 @@ leave; asks with `window.confirm` otherwise). For other state,
|
|
|
354
388
|
`useUnsavedChangesGuard({ when, message, confirm })` gives the same
|
|
355
389
|
`confirmLeave` and `beforeunload` handling. Neither depends on a router; see
|
|
356
390
|
genesis-ui's SKILL.md "Unsaved changes" for React Router and TanStack Router.
|
|
391
|
+
|
|
392
|
+
## Autosave a draft
|
|
393
|
+
|
|
394
|
+
`useDraft` saves one row as someone edits it: an answer to a questionnaire, a
|
|
395
|
+
note, a journal entry. The app supplies the writes (`create`, `update`), so it
|
|
396
|
+
works with any backend. What it guarantees:
|
|
397
|
+
|
|
398
|
+
- **When it saves:** a moment after the last change (`delayMs`, default 800).
|
|
399
|
+
Only one write runs at a time. The first save adds the row and later ones
|
|
400
|
+
change it. Nothing is written when the value is unchanged, and no row is
|
|
401
|
+
added for an empty value (`isEmpty`).
|
|
402
|
+
- **Another tab started the row:** the first write fails as a duplicate (a
|
|
403
|
+
`duplicate` write outcome, or `isDuplicate`). `adopt` then merges into that
|
|
404
|
+
row, writing only the fields changed here (`dirty`), or reports it already
|
|
405
|
+
finished, and the draft stops.
|
|
406
|
+
- **The server's copy wins** (sent elsewhere, returned by a reviewer): pass a
|
|
407
|
+
new `revision`. The draft starts again from `seed`, and `discarded` tells the
|
|
408
|
+
page that unsaved edits were dropped. `onRevision` can merge them instead.
|
|
409
|
+
A new revision that is only this draft's own save coming back (the same row,
|
|
410
|
+
holding what was saved) keeps every edit, so a revision built from the row's
|
|
411
|
+
id is safe across the first save's refetch. A revision that brings another
|
|
412
|
+
row first saves the edits pending here to the row they belong to (`idOf`,
|
|
413
|
+
default `row.id`).
|
|
414
|
+
`key` changes whenever the value is replaced from outside, so remount the
|
|
415
|
+
editor on it.
|
|
416
|
+
- **Failures:** network failures are retried with backoff. Refusals
|
|
417
|
+
(forbidden, gone, invalid) aren't retried; they set `status: "error"` and a
|
|
418
|
+
plain `error`.
|
|
419
|
+
- **Leaving the page** saves what's pending: on `pagehide` and `beforeunload`
|
|
420
|
+
(with `keepalive` passed to `update`), and on unmount.
|
|
421
|
+
- **`commit(write)`** is the final write, a submit. It waits for any write in
|
|
422
|
+
flight and stops autosave once it succeeds. If it fails, autosave carries on.
|
|
423
|
+
- **`enabled: false`** writes nothing: someone else's draft, or one already
|
|
424
|
+
sent.
|
|
425
|
+
|
|
426
|
+
```tsx
|
|
427
|
+
const draft = useDraft({
|
|
428
|
+
seed: { row: entry, value: entry?.answers ?? {} },
|
|
429
|
+
revision: `${entry?.id}:${entry?.status}`,
|
|
430
|
+
enabled: entry?.status !== "sent",
|
|
431
|
+
create: (answers) => api.addEntry(answers),
|
|
432
|
+
update: (row, answers) => api.saveEntry(row.id, answers),
|
|
433
|
+
toValue: (row) => row.answers,
|
|
434
|
+
onSaved: (row) => queryClient.setQueryData(["entry", row.id], row),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
<Editor key={draft.key} value={draft.value} onChange={draft.set} />
|
|
438
|
+
<Button onClick={() => draft.commit((row, answers) => api.send(row, answers))}>Send</Button>
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
`DraftController` is the same logic without React, for tests or other
|
|
442
|
+
frameworks.
|
|
443
|
+
|
|
444
|
+
## Query keys
|
|
445
|
+
|
|
446
|
+
Build every cache key in one place, so a write invalidates what it made stale and nothing cached for
|
|
447
|
+
one person shows to another.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
import { ViewerScope, defineQueryKeys, resourceKeys } from "@marktiderman/genesis-core";
|
|
451
|
+
|
|
452
|
+
export const worksheetKeys = defineQueryKeys("worksheet-items", {
|
|
453
|
+
// The fields that make one query distinct. Sort arrays: order never makes two keys.
|
|
454
|
+
lessonParts: (people: string[]) => ({ people: [...people].sort() }),
|
|
455
|
+
byEntry: (entryId: string) => ({ entryId }),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Reading: keys exist only for a scope. `viewer` is required; `group` and `day` when the read depends on them.
|
|
459
|
+
useQuery({ queryKey: worksheetKeys.for({ viewer: userId, group: practiceId }).lessonParts(people), queryFn });
|
|
460
|
+
|
|
461
|
+
// Invalidating:
|
|
462
|
+
queryClient.invalidateQueries({ queryKey: worksheetKeys.all }); // the whole domain
|
|
463
|
+
queryClient.invalidateQueries({ queryKey: worksheetKeys.lessonParts.all }); // one kind
|
|
464
|
+
queryClient.invalidateQueries(worksheetKeys.where({ people: personId })); // every key with this person, multi-person keys included
|
|
465
|
+
queryClient.invalidateQueries(worksheetKeys.where({ entryId }, "byEntry")); // narrowed to one query
|
|
466
|
+
queryClient.invalidateQueries({ queryKey: resourceKeys("tasks").all }); // what useResource / useOne cache
|
|
467
|
+
|
|
468
|
+
// Once, around the signed-in app: screens mount only for a known viewer, and a sign-out or a new
|
|
469
|
+
// person unmounts them and empties the cache first. (Resource keys carry no viewer; this keeps them apart.)
|
|
470
|
+
<ViewerScope viewer={session === undefined ? undefined : (session?.user.id ?? null)}>
|
|
471
|
+
<App />
|
|
472
|
+
</ViewerScope>
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Keys are `[domain, kind, { ...fields, viewer, group?, day? }]`. `where(fields, kind?)` matches a
|
|
476
|
+
field that is equal (`1` matches `"1"`; objects in any key order), an array field containing the
|
|
477
|
+
value, or for an array value an array field containing every item. Never a substring.
|
|
478
|
+
|
|
479
|
+
With a persisted cache (a native app's saved query cache), pass the viewer it was saved for as
|
|
480
|
+
`persistedViewer`, or the first known viewer empties it on every cold start. Without `ViewerScope`, call `clearViewerCache(queryClient)` in the sign-in and
|
|
481
|
+
sign-out actions, before the session changes: it also sends the old viewer's waiting undoable
|
|
482
|
+
writes while the session is still theirs. `ViewerScope` drops those unsent writes instead (by then the
|
|
483
|
+
session is the new viewer's), so prefer `clearViewerCache` in a sign-out action when undo is on.
|
|
484
|
+
|
|
485
|
+
## One-off writes
|
|
486
|
+
|
|
487
|
+
For the writes a resource page doesn't make: a database function, "one live row per key", or a few
|
|
488
|
+
writes in a row. They fail with a `DataWriteError` in plain words, like the provider's own writes.
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { callRpc, createOrAdopt, useWrite } from "@marktiderman/genesis-core";
|
|
492
|
+
|
|
493
|
+
// A database function. Your words for a code win; a guard's own `raise exception` (P0001) keeps its
|
|
494
|
+
// words; 42501, 22023 and a missing function get plain defaults; anything else a generic sentence.
|
|
495
|
+
await callRpc(supabase, "release_step", { item_id }, { messages: { "55000": "This course hasn't started yet." } });
|
|
496
|
+
|
|
497
|
+
// One live row per key: create it, or adopt the row already there (merging in what you return).
|
|
498
|
+
const { row, adopted } = await createOrAdopt(provider, "lesson_notes", { person_id, lesson_id, body }, {
|
|
499
|
+
key: ["person_id", "lesson_id"],
|
|
500
|
+
merge: (existing) => (existing.body ? null : { body }),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// In a component: pending, plain error words, and invalidation after a success.
|
|
504
|
+
const release = useWrite((itemId: string) => callRpc(supabase, "release_step", { item_id: itemId }), {
|
|
505
|
+
invalidates: [["course-steps"], resourceKeys("tasks").all],
|
|
506
|
+
});
|
|
507
|
+
<Button onClick={() => release.fire(id)} disabled={release.pending}>Release</Button>
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`run(args)` rejects on failure; `fire(args)` never does (the error shows in `error`). One write runs at a
|
|
511
|
+
time: a second click while it runs gets the same promise, not a second write.
|
|
512
|
+
|
|
513
|
+
## Days and time zones (`/dates`)
|
|
514
|
+
|
|
515
|
+
A day is a `YYYY-MM-DD` string: the date on one time zone's clock. `new Date().toISOString().slice(0, 10)`
|
|
516
|
+
gives the day in UTC. For an evening in the Americas that is already tomorrow, and for a morning in
|
|
517
|
+
Asia it is still yesterday. A server's own zone is usually UTC too. `@marktiderman/genesis-core/dates`
|
|
518
|
+
always takes the zone, so you can't fall into that by accident:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
import { personDay, dayIn, dayRangeIn, addDays, startOfWeek } from "@marktiderman/genesis-core/dates";
|
|
522
|
+
|
|
523
|
+
const today = personDay(profile); // the person's saved zone, else the device's, else UTC
|
|
524
|
+
const yesterday = addDays(today, -1);
|
|
525
|
+
const week = startOfWeek(today); // Monday; startOfWeek(today, 0) for Sunday
|
|
526
|
+
const { start, end } = dayRangeIn(today, profile.timeZone); // the instants, for created_at >= start and < end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- `personDay(person, at?)` and `personTimeZone(person)`: the person's `timeZone` when the runtime
|
|
530
|
+
knows it, else `deviceTimeZone()`, else UTC. On a server, always pass the person's zone.
|
|
531
|
+
- `dayIn(zone, at?)` and `wallClock(zone, at?)`: what the calendar and the clock say in `zone`.
|
|
532
|
+
- `startOfDayIn(day, zone)` and `dayRangeIn(day, zone)`: the instants a day starts and ends.
|
|
533
|
+
Daylight-saving days are 23 or 25 hours long, and where midnight is skipped the day starts at the
|
|
534
|
+
first instant that exists.
|
|
535
|
+
- `addDays`, `daysBetween`, `weekdayOf`, `startOfWeek` and `isDay`: plain calendar arithmetic on
|
|
536
|
+
`YYYY-MM-DD` strings, with no zone involved. They refuse days that don't exist, such as `2026-02-30`.
|
|
537
|
+
|
|
538
|
+
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
|
-
|
|
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:
|
|
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-
|
|
38
|
-
//# sourceMappingURL=chunk-
|
|
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-5NHR6ZXS.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) {
|
|
@@ -17,6 +17,7 @@ function writeOutcomeFromCode(code) {
|
|
|
17
17
|
if (code === "PGRST116") return "gone";
|
|
18
18
|
if (code === "PGRST301" || code === "PGRST302" || code === "PGRST303") return "unauthenticated";
|
|
19
19
|
if (code === "28000" || code === "28P01") return "unauthenticated";
|
|
20
|
+
if (code === "23505") return "duplicate";
|
|
20
21
|
if (/^2[23][0-9A-Z]{3}$/.test(code)) return "invalid";
|
|
21
22
|
return "error";
|
|
22
23
|
}
|
|
@@ -251,6 +252,35 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
251
252
|
await reportReadError(error, resource, operation);
|
|
252
253
|
}
|
|
253
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
|
+
}
|
|
254
284
|
const configFor = (resource) => options.resources?.[resource] ?? {};
|
|
255
285
|
const nounFor = (resource) => configFor(resource).noun ?? "record";
|
|
256
286
|
function realtimeEnabled(resource) {
|
|
@@ -365,10 +395,7 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
365
395
|
return refuseCursor(resource, "the cursor was made for a different sort; start again from the first page (cursor: null).");
|
|
366
396
|
}
|
|
367
397
|
}
|
|
368
|
-
let query =
|
|
369
|
-
params.select ?? "*",
|
|
370
|
-
...cursor ? [] : [{ count: "exact" }]
|
|
371
|
-
);
|
|
398
|
+
let query = read(resource, params.select, cursor ? void 0 : "exact");
|
|
372
399
|
query = applyFilters(query, params.filters);
|
|
373
400
|
const search = searchClauses(resource, params);
|
|
374
401
|
const keyset = after ? keysetClauses(after) : "";
|
|
@@ -429,10 +456,7 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
429
456
|
return getCursorPage(resource, params, configuredKey === void 0 ? "id" : configuredKey);
|
|
430
457
|
}
|
|
431
458
|
const run2 = (key) => {
|
|
432
|
-
let query =
|
|
433
|
-
params.select ?? "*",
|
|
434
|
-
{ count: "exact" }
|
|
435
|
-
);
|
|
459
|
+
let query = read(resource, params.select, "exact");
|
|
436
460
|
query = applyFilters(query, params.filters);
|
|
437
461
|
const clauses = searchClauses(resource, params);
|
|
438
462
|
if (clauses) {
|
|
@@ -456,7 +480,7 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
456
480
|
};
|
|
457
481
|
const guessKey = configuredKey === void 0 && !keylessResources.has(resource);
|
|
458
482
|
let result = await run2(configuredKey ?? (keylessResources.has(resource) ? false : "id"));
|
|
459
|
-
if (guessKey && isMissingColumn(result.error, resource, "id")) {
|
|
483
|
+
if (guessKey && isMissingColumn(result.error, readName(resource), "id")) {
|
|
460
484
|
keylessResources.add(resource);
|
|
461
485
|
if (process.env.NODE_ENV !== "production") {
|
|
462
486
|
console.warn(
|
|
@@ -469,13 +493,13 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
469
493
|
return { data: result.data ?? [], total: result.count ?? 0 };
|
|
470
494
|
},
|
|
471
495
|
async getOne(resource, params) {
|
|
472
|
-
const result = await
|
|
496
|
+
const result = await read(resource, params.select).eq("id", params.id).single();
|
|
473
497
|
await throwIfReadError(result, resource, "getOne");
|
|
474
498
|
return { data: result.data };
|
|
475
499
|
},
|
|
476
500
|
async getMany(resource, params) {
|
|
477
501
|
if (params.ids.length === 0) return { data: [] };
|
|
478
|
-
const result = await
|
|
502
|
+
const result = await read(resource, void 0).in("id", params.ids);
|
|
479
503
|
await throwIfReadError(result, resource, "getMany");
|
|
480
504
|
return { data: result.data ?? [] };
|
|
481
505
|
},
|
|
@@ -555,10 +579,10 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
555
579
|
};
|
|
556
580
|
},
|
|
557
581
|
async create(resource, params) {
|
|
558
|
-
const row = await
|
|
582
|
+
const row = await tableValues(
|
|
559
583
|
resource,
|
|
560
|
-
action: "create"
|
|
561
|
-
|
|
584
|
+
await prepare(resource, params.data, { resource, action: "create" })
|
|
585
|
+
);
|
|
562
586
|
const override = configFor(resource).create;
|
|
563
587
|
let record;
|
|
564
588
|
if (override) {
|
|
@@ -587,11 +611,11 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
587
611
|
},
|
|
588
612
|
async update(resource, params) {
|
|
589
613
|
const { id } = params;
|
|
590
|
-
const row = await
|
|
614
|
+
const row = await tableValues(
|
|
591
615
|
resource,
|
|
592
|
-
action: "update",
|
|
616
|
+
await prepare(resource, params.data, { resource, action: "update", id }),
|
|
593
617
|
id
|
|
594
|
-
|
|
618
|
+
);
|
|
595
619
|
const override = configFor(resource).update;
|
|
596
620
|
let record;
|
|
597
621
|
if (override) {
|
|
@@ -664,6 +688,60 @@ function createSupabaseProvider(client, options = {}) {
|
|
|
664
688
|
return provider;
|
|
665
689
|
}
|
|
666
690
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|