@tiaforge-ui/sinter 0.1.0 → 0.3.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 (45) hide show
  1. package/README.md +39 -12
  2. package/dist/check/index.d.ts +4 -1
  3. package/dist/check/index.js +9 -8
  4. package/dist/check/shadow.d.ts +3 -1
  5. package/dist/check/shadow.js +53 -5
  6. package/dist/cli.js +2 -2
  7. package/dist/compiler/check-unit.d.ts +26 -2
  8. package/dist/compiler/compile.d.ts +4 -0
  9. package/dist/compiler/compile.js +314 -30
  10. package/dist/compiler/diagnostic-docs.js +20 -5
  11. package/dist/compiler/diagnostics.d.ts +3 -0
  12. package/dist/compiler/diagnostics.js +3 -0
  13. package/dist/compiler/expr.d.ts +24 -0
  14. package/dist/compiler/expr.js +53 -8
  15. package/dist/compiler/index.d.ts +3 -2
  16. package/dist/compiler/index.js +70 -16
  17. package/dist/compiler/vocabulary.d.ts +1 -0
  18. package/dist/compiler/vocabulary.js +9 -3
  19. package/dist/each.d.ts +3 -0
  20. package/dist/each.js +121 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/ir.d.ts +30 -1
  23. package/dist/listbox.d.ts +3 -0
  24. package/dist/listbox.js +66 -0
  25. package/dist/lsp/typescript.js +1 -1
  26. package/dist/lsp/workspace.js +3 -2
  27. package/dist/paginate.js +18 -4
  28. package/dist/project.d.ts +6 -0
  29. package/dist/project.js +22 -19
  30. package/dist/runtime/app.d.ts +35 -1
  31. package/dist/runtime/app.js +178 -40
  32. package/dist/runtime/config.d.ts +10 -3
  33. package/dist/runtime/config.js +10 -3
  34. package/dist/runtime/request.d.ts +1 -1
  35. package/dist/runtime/request.js +6 -1
  36. package/dist/runtime/store.d.ts +7 -4
  37. package/dist/runtime/store.js +154 -41
  38. package/dist/sse.js +8 -5
  39. package/dist/testing/index.d.ts +2 -0
  40. package/dist/testing/index.js +17 -2
  41. package/dist/vite.js +4 -2
  42. package/docs/diagnostics.md +35 -5
  43. package/html-custom-data.json +46 -2
  44. package/llms.txt +112 -6
  45. package/package.json +6 -1
@@ -30,11 +30,10 @@ const after = (el) => {
30
30
  const ET = EventTarget.prototype;
31
31
  /** Per-element registries, kept on the element (cheaper than WeakMaps on the row creation path). */
32
32
  const HANDLERS = Symbol('sinter.on'), MODEL = Symbol('sinter.model'), STATE = Symbol('sinter.state');
33
- const dialogsIn = new WeakMap(), indexReaders = new WeakMap();
33
+ const indexReaders = new WeakMap();
34
34
  /** row templates: whether each holds one element (see oneElement()) */
35
35
  const prepared = new WeakMap();
36
36
  const simple = new WeakMap(), SIMPLE = new Set(['text', 'attr', 'class', 'style', 'show', 'prop', 'on']);
37
- const hasDialog = (tpl) => dialogsIn.get(tpl) ?? (dialogsIn.set(tpl, !!tpl.content.querySelector('dialog')), dialogsIn.get(tpl));
38
37
  /** The instance mount() created on `root`; throws if none. */
39
38
  export function instance(root) {
40
39
  const app = instances.get(root);
@@ -56,6 +55,8 @@ export function mount(root, o) {
56
55
  const types = new Set(), selects = new Set(), deferred = new Set();
57
56
  const listeners = new Map(), cleanups = [];
58
57
  const shared = new Map();
58
+ /** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
59
+ const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
59
60
  const rootRbs = [], rootScopes = [], found = new Map();
60
61
  let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
61
62
  const res = (name) => {
@@ -76,6 +77,27 @@ export function mount(root, o) {
76
77
  };
77
78
  /** Elements written through callbacks (`$.$m(row, …)`) since the last flush. */
78
79
  const marked = new Set();
80
+ /** debounced handlers' pending timers */
81
+ const timers = new WeakMap();
82
+ /** action calls' signal: aborts when the page is hidden */
83
+ let hide = new AbortController();
84
+ /** runs an action function; it counts as busy (for the testing module's settle) while it runs */
85
+ async function invoke(run, args, ctx) {
86
+ app.busy++;
87
+ try {
88
+ return await fns[run](args, ctx);
89
+ }
90
+ finally {
91
+ app.busy--;
92
+ }
93
+ }
94
+ /** a failed call: SinterErrors as they are, anything else with status null */
95
+ const callError = (e) => {
96
+ if (e instanceof SinterError)
97
+ return e;
98
+ const detail = (e instanceof Error ? e.message : typeof e == 'string' ? e : '') || null;
99
+ return new SinterError(null, e, detail ?? String(e), detail);
100
+ };
79
101
  /** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
80
102
  let rowHits = new Map();
81
103
  /**
@@ -127,7 +149,7 @@ export function mount(root, o) {
127
149
  return false;
128
150
  };
129
151
  const app = {
130
- ir, fns, env, req, transport: o.transport ?? (() => undefined), ext: {},
152
+ ir, fns, env, req, root, transport: o.transport ?? (() => undefined), ext: {},
131
153
  beforeFlush: [], afterFlush: [], onListen: [], states: [], busy: 0,
132
154
  get isDirty() { return dirty.length > 0 || scheduled; },
133
155
  dev: false,
@@ -170,6 +192,7 @@ export function mount(root, o) {
170
192
  c();
171
193
  instances.delete(root);
172
194
  },
195
+ scopeOf: (i) => (i < 0 ? app.page : rootScopes[i] ?? app.page),
173
196
  url(tpl, s) {
174
197
  let out = '', query = false, dropped = false;
175
198
  tpl.forEach((p, i) => {
@@ -251,12 +274,14 @@ export function mount(root, o) {
251
274
  } };
252
275
  },
253
276
  storeFns: (el) => ({
277
+ showModal: (id) => { const d = dialog(id); if (d && !d.open)
278
+ d.showModal(); },
254
279
  refresh: (n) => res(n).refetch(),
255
280
  upsert: forward('upsert'),
256
281
  patch: forward('patch'),
257
282
  remove: forward('remove'),
258
283
  page: (n, t) => app.ext.paginate.page(n, t),
259
- close: () => el?.closest('dialog')?.close(),
284
+ close: (id) => (id ? dialog(id) : el?.closest('dialog'))?.close(),
260
285
  reset: () => {
261
286
  const form = el?.closest('form');
262
287
  if (!form)
@@ -298,6 +323,10 @@ export function mount(root, o) {
298
323
  }
299
324
  for (const def of ir.resources)
300
325
  createLoader(def);
326
+ // action calls see the page leave through ctx.signal
327
+ if (ir.actions.some((x) => x.call) || ir.resources.some((x) => x.call)) {
328
+ app.addCleanup(env.listen('pagehide', () => { hide.abort(); hide = new AbortController(); }));
329
+ }
301
330
  // listeners for every event type the page binds, rows included
302
331
  listen('toggle');
303
332
  for (const nd of [...ir.roots, ...ir.templates.flatMap((t) => t.nodes)])
@@ -390,10 +419,10 @@ export function mount(root, o) {
390
419
  cleanups.push(() => ET.removeEventListener.call(root, type, dispatch, true));
391
420
  }
392
421
  /**
393
- * `dialogs`: whether these elements can sit in a closed dialog of their own block. Rows only
394
- * need the check when their template contains a dialog; outer dialogs defer the whole list.
422
+ * Only elements the compiler marked as in a dialog of their own block (`dl`) are checked for a
423
+ * closed dialog; dialogs outside a template defer the whole list.
395
424
  */
396
- function bindNodes(defs, els, base, rbs, dialogs = true) {
425
+ function bindNodes(defs, els, base, rbs) {
397
426
  // scopes of elements that declare state (and their descendants); most rows have none
398
427
  let scopes = null;
399
428
  for (let i = 0; i < defs.length; i++) {
@@ -416,7 +445,7 @@ export function mount(root, o) {
416
445
  rootScopes[i] = s;
417
446
  if (el)
418
447
  for (const b of nd.b) {
419
- const rb = { b, el, s, v: UNSET, c: dialogs };
448
+ const rb = { b, el, s, v: UNSET, c: !!nd.dl };
420
449
  if (b.k == 'on') {
421
450
  const h = el[HANDLERS];
422
451
  el[HANDLERS] = h == null ? rb : Array.isArray(h) ? (h.push(rb), h) : [h, rb];
@@ -484,7 +513,7 @@ export function mount(root, o) {
484
513
  /** Evaluates and writes one binding if needed; false while it waits in a closed dialog. */
485
514
  function process(rb, level) {
486
515
  const force = level == 2 || (level == 1 && !!rb.b.i);
487
- if (!force && !deferred.has(rb) && !(rb.b.d && hit(depsOf(rb.b, rb.b.d), rb.s)))
516
+ if (!force && !(deferred.size && deferred.has(rb)) && !(rb.b.d && hit(rb.d ??= depsOf(rb.b, rb.b.d), rb.s)))
488
517
  return true;
489
518
  if (rb.c && rb.el.closest('dialog:not([open])')) {
490
519
  deferred.add(rb);
@@ -762,7 +791,7 @@ export function mount(root, o) {
762
791
  els[k] = node && node.localName == defs[k].tag ? node : null;
763
792
  }
764
793
  }
765
- if (isSimple(tpl, t)) {
794
+ if (isSimple(t)) {
766
795
  // only value bindings and handlers: bind and write each once, as walk(rbs, 2) would
767
796
  for (let k = 0; k < defs.length; k++) {
768
797
  const el = els[k];
@@ -782,7 +811,7 @@ export function mount(root, o) {
782
811
  }
783
812
  }
784
813
  else {
785
- bindNodes(defs, els, s, rbs, hasDialog(tpl));
814
+ bindNodes(defs, els, s, rbs);
786
815
  walk(rbs, 2);
787
816
  }
788
817
  const nodes = [];
@@ -804,10 +833,10 @@ export function mount(root, o) {
804
833
  return one;
805
834
  }
806
835
  /** A row template whose elements only have value bindings and handlers (no state, model, nested template or dialog). */
807
- function isSimple(tpl, t) {
836
+ function isSimple(t) {
808
837
  let v = simple.get(t);
809
838
  if (v == null) {
810
- v = !hasDialog(tpl) && t.nodes.every((nd) => nd.state == null && nd.b.every((b) => SIMPLE.has(b.k)));
839
+ v = t.nodes.every((nd) => !nd.dl && nd.state == null && nd.b.every((b) => SIMPLE.has(b.k)));
811
840
  simple.set(t, v);
812
841
  }
813
842
  return v;
@@ -843,7 +872,7 @@ export function mount(root, o) {
843
872
  const h = el[HANDLERS], hs = h && (Array.isArray(h) ? h : [h]).filter((x) => x.b.n == e.type);
844
873
  if (hs?.length) {
845
874
  for (const h of hs)
846
- h.b.x != null ? trigger(h, e) : handle(h, h.b.f, e, h.b.w);
875
+ h.b.x != null ? trigger(h, e) : h.b.ms ? later(h, e) : handle(h, h.b.f, e, h.b.w);
847
876
  return;
848
877
  }
849
878
  if (el == root)
@@ -864,6 +893,16 @@ export function mount(root, o) {
864
893
  if (writes?.some((w) => w.startsWith('$url')))
865
894
  writeUrl();
866
895
  }
896
+ /** A debounced handler: runs once `ms` after the last event, with that event. */
897
+ function later(rb, e) {
898
+ const t = timers.get(rb);
899
+ if (t) {
900
+ env.clearTimeout(t);
901
+ app.busy--;
902
+ }
903
+ app.busy++;
904
+ timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
905
+ }
867
906
  // ---- actions
868
907
  function trigger(rb, e) {
869
908
  const a = ir.actions[rb.b.x], el = rb.el;
@@ -889,29 +928,49 @@ export function mount(root, o) {
889
928
  if (!get && a.concurrency == 'block' && st.n)
890
929
  return;
891
930
  const s = child(rb.s, { $el: rb.el, $event: e });
892
- let url, body;
931
+ let url = '', body;
893
932
  try {
894
- url = app.url(a.url, s);
895
- body = a.body != null ? fns[a.body](s) : a.form && !get && a.method != 'DELETE' ? serialize(rb.el) : undefined;
933
+ // a call's arguments are taken when it is triggered, like a request's URL and body
934
+ if (a.call)
935
+ body = fns[a.call.args](s);
936
+ else {
937
+ url = app.url(a.url, s);
938
+ const fields = a.form && !get && a.method != 'DELETE';
939
+ body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? formFields(rb.el) : serialize(rb.el)) : undefined;
940
+ if (a.enc == 'form')
941
+ body = formBody(body);
942
+ }
896
943
  }
897
944
  catch (err) {
898
945
  return unhandled(toError(err), rb.el);
899
946
  }
900
- const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
901
- if (get) {
902
- st.c?.abort();
903
- return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
904
- }
905
- const run = () => void send(rb, a, st, url, body, drop);
906
- if (!st.n)
907
- run();
908
- else if (a.concurrency == 'latest') {
909
- st.w ? st.w.drop() : app.busy++;
910
- st.w = { run, drop };
947
+ // a question first: the action runs only when the user agrees
948
+ const question = a.confirm != null ? fns[a.confirm](s) : null;
949
+ if (question) {
950
+ const yes = config.confirm(String(question));
951
+ if (yes instanceof Promise)
952
+ return void yes.then((y) => y && go());
953
+ if (!yes)
954
+ return;
911
955
  }
912
- else {
913
- app.busy++;
914
- st.q.push(run);
956
+ go();
957
+ function go() {
958
+ const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
959
+ if (get) {
960
+ st.c?.abort();
961
+ return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
962
+ }
963
+ const run = () => void send(rb, a, st, url, body, drop);
964
+ if (!st.n)
965
+ run();
966
+ else if (a.concurrency == 'latest') {
967
+ st.w ? st.w.drop() : app.busy++;
968
+ st.w = { run, drop };
969
+ }
970
+ else {
971
+ app.busy++;
972
+ st.q.push(run);
973
+ }
915
974
  }
916
975
  }
917
976
  async function send(rb, a, st, url, body, drop, c) {
@@ -924,7 +983,9 @@ export function mount(root, o) {
924
983
  x.disabled = true;
925
984
  }
926
985
  try {
927
- const result = await req.send(url, { method: a.method, body, signal: c?.signal });
986
+ const result = a.call
987
+ ? await invoke(a.call.run, body, { signal: hide.signal, request: (u, i) => req.send(u, i), el })
988
+ : await req.send(url, { method: a.method, body, signal: c?.signal });
928
989
  if (c && st.c != c)
929
990
  return;
930
991
  drop();
@@ -935,7 +996,7 @@ export function mount(root, o) {
935
996
  drop();
936
997
  if (isAbort(err) || (c && st.c != c))
937
998
  return;
938
- const error = toError(err);
999
+ const error = a.call ? callError(err) : toError(err);
939
1000
  a.catch != null ? handle(rb, a.catch, null, a.w, { $error: error }) : unhandled(error, el);
940
1001
  }
941
1002
  finally {
@@ -968,14 +1029,34 @@ export function mount(root, o) {
968
1029
  }
969
1030
  // ---- resources
970
1031
  function createLoader(def) {
971
- const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x) });
1032
+ const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x), freeze: app.dev });
972
1033
  r.onDirty = (what) => app.mark((what == 'data' ? '$r.' : '$meta.') + def.name);
973
1034
  resources.set(def.name, r);
974
1035
  Object.defineProperty(app.page.$r, def.name, { get: r.value, enumerable: true });
975
1036
  app.page.$meta[def.name] = r.meta;
976
1037
  let url = null, c = null, seq = 0, again = null;
1038
+ const scope = () => app.scopeOf(def.scope);
1039
+ /** `call`: the arguments of the current load; the identity of a load is its arguments' JSON */
1040
+ let args = [], calls = 0;
977
1041
  const resolve = () => {
978
- const s = def.scope < 0 ? app.page : rootScopes[def.scope] ?? app.page;
1042
+ const s = scope();
1043
+ if (def.call) {
1044
+ try {
1045
+ if (def.when != null && !fns[def.when](s))
1046
+ return null;
1047
+ args = fns[def.call.args](s);
1048
+ try {
1049
+ return 'call:' + JSON.stringify(args);
1050
+ }
1051
+ catch {
1052
+ return `call:#${++calls}`;
1053
+ }
1054
+ }
1055
+ catch (e) {
1056
+ warn(17, def.name, e.message);
1057
+ return null;
1058
+ }
1059
+ }
979
1060
  try {
980
1061
  return def.when != null && !fns[def.when](s) ? null : app.url(def.src, s);
981
1062
  }
@@ -1010,8 +1091,23 @@ export function mount(root, o) {
1010
1091
  };
1011
1092
  /** the request URL for the current page of `u` */
1012
1093
  const request = (u, m = l.pager?.current()) => withParams(u, m ? l.pager.params(m) : null);
1094
+ const fail = (e) => {
1095
+ const error = toError(e);
1096
+ r.meta.error = error;
1097
+ r.onDirty('meta');
1098
+ unhandled(error, null, def.name);
1099
+ };
1013
1100
  const l = {
1014
1101
  def, res: r, app, pager: null,
1102
+ scope, get: loadShared, failed: fail, loading,
1103
+ items(body) {
1104
+ let items = pick(def.items, body);
1105
+ if (!Array.isArray(items)) {
1106
+ warn(12, def.name, def.items ?? '(response)');
1107
+ items = [];
1108
+ }
1109
+ return items.map(clean);
1110
+ },
1015
1111
  start() {
1016
1112
  const el = def.data != null ? found.get(def.data) : null;
1017
1113
  let data;
@@ -1069,7 +1165,9 @@ export function mount(root, o) {
1069
1165
  loading(true);
1070
1166
  try {
1071
1167
  const req = withParams(url, m ? l.pager.params(m) : null);
1072
- const body = await loadShared(req, ctl.signal);
1168
+ const body = def.call
1169
+ ? await invoke(def.call.run, args, { signal: ctl.signal, request: (u, i) => app.req.send(u, i), el: null })
1170
+ : await loadShared(req, ctl.signal);
1073
1171
  if (n != seq)
1074
1172
  return;
1075
1173
  store(body, req, m);
@@ -1080,10 +1178,7 @@ export function mount(root, o) {
1080
1178
  catch (e) {
1081
1179
  if (n != seq || isAbort(e))
1082
1180
  return;
1083
- const error = toError(e);
1084
- r.meta.error = error;
1085
- r.onDirty('meta');
1086
- unhandled(error, null, def.name);
1181
+ fail(def.call ? callError(e) : e);
1087
1182
  }
1088
1183
  finally {
1089
1184
  // writes made while this load ran win over its (older) response
@@ -1095,6 +1190,8 @@ export function mount(root, o) {
1095
1190
  }
1096
1191
  },
1097
1192
  };
1193
+ if (def.each)
1194
+ app.ext.each.fanOut(l);
1098
1195
  if (def.paginate)
1099
1196
  l.pager = app.ext.paginate.pager(l);
1100
1197
  r.loader = l;
@@ -1156,6 +1253,47 @@ export function lis(seq) {
1156
1253
  return keep;
1157
1254
  }
1158
1255
  /** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
1256
+ /**
1257
+ * A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
1258
+ * arrays as repeated keys; null and undefined values are left out. Files stay multipart.
1259
+ */
1260
+ export function formBody(body) {
1261
+ if (body == null)
1262
+ return null;
1263
+ if (body instanceof FormData) {
1264
+ const out = new URLSearchParams();
1265
+ for (const [k, v] of body) {
1266
+ if (typeof v != 'string')
1267
+ return body;
1268
+ out.append(k, v);
1269
+ }
1270
+ return out;
1271
+ }
1272
+ if (body instanceof URLSearchParams)
1273
+ return body;
1274
+ const out = new URLSearchParams();
1275
+ for (const [k, v] of Object.entries(body)) {
1276
+ for (const x of Array.isArray(v) ? v : [v])
1277
+ if (x != null)
1278
+ out.append(k, typeof x == 'object' ? JSON.stringify(x) : String(x));
1279
+ }
1280
+ return out;
1281
+ }
1282
+ /** A form's fields as browsers submit them; with a file input, multipart. */
1283
+ function formFields(form) {
1284
+ const out = new URLSearchParams();
1285
+ for (const c of form.elements) {
1286
+ if (!c.name || c.disabled || /^(submit|button|reset|image|fieldset|output)$/.test(c.type))
1287
+ continue;
1288
+ if (c.type == 'file')
1289
+ return new FormData(form);
1290
+ if ((c.type == 'checkbox' || c.type == 'radio') && !c.checked)
1291
+ continue;
1292
+ for (const v of c.type == 'select-multiple' ? [...c.selectedOptions].map((o) => o.value) : [c.value])
1293
+ out.append(c.name, v);
1294
+ }
1295
+ return out;
1296
+ }
1159
1297
  export function serialize(form) {
1160
1298
  const controls = [...form.elements];
1161
1299
  if (controls.some((c) => c.type == 'file'))
@@ -13,17 +13,24 @@ export interface Config {
13
13
  origins: string[];
14
14
  /** the transport for a `<sn-sync>` stream URL; `undefined` (or no function) means EventSource */
15
15
  transport: ((url: string) => Transport | undefined) | null;
16
+ /** `data-sn-confirm`: asks the question; the action runs on true (window.confirm by default) */
17
+ confirm: (question: string) => boolean | Promise<boolean>;
16
18
  env: Env;
17
19
  }
18
20
  export declare const config: Config;
19
21
  export declare function configure(options: Partial<Omit<Config, 'env'>> & {
20
22
  env?: Partial<Env>;
21
23
  }): void;
22
- /** `$error`: a non-2xx response, a network error (status 0) or a refused request. */
24
+ /** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
23
25
  export declare class SinterError extends Error {
24
- readonly status: number;
26
+ readonly status: number | null;
25
27
  readonly body: unknown;
26
- constructor(status: number, body: unknown, message: string);
28
+ readonly detail: string | null;
29
+ /**
30
+ * @param detail what went wrong in words: the server's message or text, or a thrown error's
31
+ * message; null when there is none (`message` then falls back to the HTTP status)
32
+ */
33
+ constructor(status: number | null, body: unknown, message: string, detail?: string | null);
27
34
  }
28
35
  /** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
29
36
  export declare const CACHE_PREFIX = "sn:c:";
@@ -2,7 +2,8 @@
2
2
  // comes only from configure() in JavaScript, never from the DOM.
3
3
  import { defaultEnv } from './env.js';
4
4
  export const config = {
5
- request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null, env: defaultEnv,
5
+ request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null,
6
+ confirm: (question) => globalThis.confirm(question), env: defaultEnv,
6
7
  };
7
8
  export function configure(options) {
8
9
  const { env, ...rest } = options;
@@ -10,14 +11,20 @@ export function configure(options) {
10
11
  if (env)
11
12
  config.env = { ...config.env, ...env };
12
13
  }
13
- /** `$error`: a non-2xx response, a network error (status 0) or a refused request. */
14
+ /** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
14
15
  export class SinterError extends Error {
15
16
  status;
16
17
  body;
17
- constructor(status, body, message) {
18
+ detail;
19
+ /**
20
+ * @param detail what went wrong in words: the server's message or text, or a thrown error's
21
+ * message; null when there is none (`message` then falls back to the HTTP status)
22
+ */
23
+ constructor(status, body, message, detail = null) {
18
24
  super(message);
19
25
  this.status = status;
20
26
  this.body = body;
27
+ this.detail = detail;
21
28
  this.name = 'SinterError';
22
29
  }
23
30
  }
@@ -3,7 +3,7 @@ import type { Env } from './env.js';
3
3
  export declare const TIMEOUT = 30000;
4
4
  export interface SendInit {
5
5
  method?: string;
6
- /** JSON-serialized unless FormData */
6
+ /** JSON-serialized unless FormData or URLSearchParams (sent form-encoded) */
7
7
  body?: unknown;
8
8
  headers?: Record<string, string>;
9
9
  signal?: AbortSignal | undefined;
@@ -44,6 +44,10 @@ export function createRequestLayer(env, chain) {
44
44
  let body = init.body;
45
45
  if (body == null || method == 'GET')
46
46
  body = undefined;
47
+ else if (body instanceof URLSearchParams) {
48
+ body = body.toString();
49
+ headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
50
+ }
47
51
  else if (!(body instanceof FormData)) {
48
52
  body = JSON.stringify(body);
49
53
  headers.set('Content-Type', 'application/json');
@@ -71,7 +75,8 @@ export function createRequestLayer(env, chain) {
71
75
  const res = await raw(url, init), body = await parseBody(res);
72
76
  if (!res.ok) {
73
77
  const b = body, m = typeof body == 'string' ? body.length < 200 && body.trim() : b?.message ?? b?.error;
74
- throw new SinterError(res.status, body, typeof m == 'string' && m ? m : `${res.status} ${res.statusText}`.trim());
78
+ const detail = typeof m == 'string' && m ? m : null;
79
+ throw new SinterError(res.status, body, detail ?? `${res.status} ${res.statusText}`.trim(), detail);
75
80
  }
76
81
  return body;
77
82
  }
@@ -29,14 +29,14 @@ export type Op = {
29
29
  export interface Meta {
30
30
  loading: boolean;
31
31
  error: {
32
- status: number;
32
+ status: number | null;
33
33
  body: unknown;
34
34
  message: string;
35
35
  } | null;
36
36
  loadedAt: number | null;
37
37
  pending: number;
38
38
  total: number | null;
39
- page?: number;
39
+ page?: number | null;
40
40
  pages?: number | null;
41
41
  from?: number | null;
42
42
  to?: number | null;
@@ -50,6 +50,8 @@ export interface ResourceOptions {
50
50
  warn?: (code: number, ...args: unknown[]) => void;
51
51
  /** applied to every stored item (dev builds freeze them) */
52
52
  prep?: <T>(item: T) => T;
53
+ /** hand out frozen lists (dev builds) */
54
+ freeze?: boolean;
53
55
  }
54
56
  /** The public store handle (`app.resource(name)`), plus the hooks loaders and features use. */
55
57
  export interface Resource<T extends object = any> {
@@ -66,7 +68,8 @@ export interface Resource<T extends object = any> {
66
68
  upsert(item: T): void;
67
69
  patch(key: string | T, fields: Partial<T>): void;
68
70
  remove(key: string | T): void;
69
- refetch(): Promise<void>;
71
+ /** `hint`: a server event's payload; an `each` resource reloads only the source it names */
72
+ refetch(hint?: object): Promise<void>;
70
73
  subscribe(fn: (change: Change<T>) => void): () => void;
71
74
  keyOf(item: string | T): string;
72
75
  /**
@@ -86,7 +89,7 @@ export interface Resource<T extends object = any> {
86
89
  /** while set, upsert/patch/remove are recorded instead of applied */
87
90
  recording: Op[] | null;
88
91
  loader: {
89
- refetch(): Promise<void>;
92
+ refetch(hint?: object): Promise<void>;
90
93
  } | null;
91
94
  onDirty(what: 'data' | 'meta'): void;
92
95
  }