@lotics/app-sdk 0.63.2 → 0.64.1

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/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
- | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
19
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
21
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
22
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
@@ -40,6 +40,8 @@ export { row, readLinks, readFiles, readLocked } from "./row.js";
40
40
  export type { ResolvedLink, AppFile } from "./row.js";
41
41
  export { useOptimistic } from "./use_optimistic.js";
42
42
  export type { OptimisticApi } from "./use_optimistic.js";
43
+ export { useNewRecord, newRecordId } from "./new_record.js";
44
+ export type { NewRecordApi } from "./new_record.js";
43
45
  export { useRecents } from "./use_recents.js";
44
46
  export type { RecentsApi, RecentsOptions } from "./use_recents.js";
45
47
  export { useUrlState } from "./use_url_state.js";
package/dist/src/index.js CHANGED
@@ -28,6 +28,7 @@ export { readMembers } from "./members.js";
28
28
  export { readSelect } from "./select.js";
29
29
  export { row, readLinks, readFiles, readLocked } from "./row.js";
30
30
  export { useOptimistic } from "./use_optimistic.js";
31
+ export { useNewRecord, newRecordId } from "./new_record.js";
31
32
  export { useRecents } from "./use_recents.js";
32
33
  export { useUrlState } from "./use_url_state.js";
33
34
  export { urlParam } from "./url_params.js";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Mint a record id locally, in the shape the platform mints.
3
+ *
4
+ * The generator is restated here rather than imported because the canonical one
5
+ * lives in a package that is not published, and this one is. The duplication is
6
+ * safe by construction rather than by discipline: the server validates the shape on
7
+ * every write, so a client that drifted would be rejected loudly at the first call
8
+ * instead of quietly persisting a malformed primary key.
9
+ *
10
+ * Uses `crypto.getRandomValues` — available in every browser this SDK runs in — and
11
+ * rejection-samples so each character is uniformly drawn from the alphabet. A plain
12
+ * `% 62` over bytes would bias the first 8 characters, which is a poor property for
13
+ * something used as a primary key.
14
+ */
15
+ export declare function newRecordId(): string;
16
+ export interface NewRecordApi<P> {
17
+ /**
18
+ * The id this surface writes under, stable from the first render — before the
19
+ * record exists, and unchanged by its creation.
20
+ */
21
+ id: string;
22
+ /**
23
+ * Persist a patch. The first call creates the record, every later one updates it.
24
+ * Calls are serialised, so firing several before the first resolves is safe.
25
+ *
26
+ * Rejects with whatever `create`/`update` rejected with; a failed create leaves the
27
+ * record uncreated and the next call will try again.
28
+ */
29
+ save: (patch: P) => Promise<void>;
30
+ }
31
+ /**
32
+ * A record that does not exist yet, named before it does.
33
+ *
34
+ * The problem this removes: when the server mints the id, a surface editing a new
35
+ * record has nothing to identify it by until the first write returns. Everything
36
+ * keyed on that id — the route, the drawer, a list selection — therefore changes
37
+ * identity mid-edit, which React resolves by remounting the surface the user is
38
+ * typing into. Minting the id locally makes it stable from the first render, so the
39
+ * create stops being an event the UI has to survive.
40
+ *
41
+ * Creation still happens on the first write, not on mount: a surface the user opens
42
+ * and abandons should leave nothing behind.
43
+ *
44
+ * The transport is the caller's. Like `useOptimistic`, this hook has no idea how the
45
+ * app persists anything — it takes `create` and `update` thunks and owns only the id
46
+ * and the ordering, which is the part that is easy to get wrong:
47
+ *
48
+ * - Two blur-saves fired before the first resolves must not both create. That is a
49
+ * duplicate record, and it is the failure this exists to prevent.
50
+ * - A save arriving mid-create must wait for it, or it updates a row that is not
51
+ * there yet.
52
+ * - A create that FAILS must not latch. Otherwise every later save updates a record
53
+ * that was never written, and the user's work goes nowhere while looking saved.
54
+ *
55
+ * ```tsx
56
+ * const { id, save } = useNewRecord({
57
+ * create: (id, patch) => createCustomer({ record_id: id, ...patch }),
58
+ * update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
59
+ * onCreated: refetch,
60
+ * });
61
+ * <InlineText onBlur={(name) => save({ name })} />
62
+ * ```
63
+ */
64
+ export declare function useNewRecord<P>(opts: {
65
+ create: (id: string, patch: P) => Promise<unknown>;
66
+ update: (id: string, patch: P) => Promise<unknown>;
67
+ /** Runs once, after the record first exists. Pass a list `refetch` to reveal it. */
68
+ onCreated?: (id: string) => void;
69
+ }): NewRecordApi<P>;
@@ -0,0 +1,117 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3
+ const ID_LENGTH = 12;
4
+ /**
5
+ * Mint a record id locally, in the shape the platform mints.
6
+ *
7
+ * The generator is restated here rather than imported because the canonical one
8
+ * lives in a package that is not published, and this one is. The duplication is
9
+ * safe by construction rather than by discipline: the server validates the shape on
10
+ * every write, so a client that drifted would be rejected loudly at the first call
11
+ * instead of quietly persisting a malformed primary key.
12
+ *
13
+ * Uses `crypto.getRandomValues` — available in every browser this SDK runs in — and
14
+ * rejection-samples so each character is uniformly drawn from the alphabet. A plain
15
+ * `% 62` over bytes would bias the first 8 characters, which is a poor property for
16
+ * something used as a primary key.
17
+ */
18
+ export function newRecordId() {
19
+ const max = 256 - (256 % ID_ALPHABET.length);
20
+ let out = "";
21
+ const buf = new Uint8Array(ID_LENGTH * 2);
22
+ while (out.length < ID_LENGTH) {
23
+ crypto.getRandomValues(buf);
24
+ for (let i = 0; i < buf.length && out.length < ID_LENGTH; i++) {
25
+ if (buf[i] < max)
26
+ out += ID_ALPHABET[buf[i] % ID_ALPHABET.length];
27
+ }
28
+ }
29
+ return `rec_${out}`;
30
+ }
31
+ /**
32
+ * A record that does not exist yet, named before it does.
33
+ *
34
+ * The problem this removes: when the server mints the id, a surface editing a new
35
+ * record has nothing to identify it by until the first write returns. Everything
36
+ * keyed on that id — the route, the drawer, a list selection — therefore changes
37
+ * identity mid-edit, which React resolves by remounting the surface the user is
38
+ * typing into. Minting the id locally makes it stable from the first render, so the
39
+ * create stops being an event the UI has to survive.
40
+ *
41
+ * Creation still happens on the first write, not on mount: a surface the user opens
42
+ * and abandons should leave nothing behind.
43
+ *
44
+ * The transport is the caller's. Like `useOptimistic`, this hook has no idea how the
45
+ * app persists anything — it takes `create` and `update` thunks and owns only the id
46
+ * and the ordering, which is the part that is easy to get wrong:
47
+ *
48
+ * - Two blur-saves fired before the first resolves must not both create. That is a
49
+ * duplicate record, and it is the failure this exists to prevent.
50
+ * - A save arriving mid-create must wait for it, or it updates a row that is not
51
+ * there yet.
52
+ * - A create that FAILS must not latch. Otherwise every later save updates a record
53
+ * that was never written, and the user's work goes nowhere while looking saved.
54
+ *
55
+ * ```tsx
56
+ * const { id, save } = useNewRecord({
57
+ * create: (id, patch) => createCustomer({ record_id: id, ...patch }),
58
+ * update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
59
+ * onCreated: refetch,
60
+ * });
61
+ * <InlineText onBlur={(name) => save({ name })} />
62
+ * ```
63
+ */
64
+ export function useNewRecord(opts) {
65
+ const idRef = useRef("");
66
+ if (idRef.current === "")
67
+ idRef.current = newRecordId();
68
+ // Callbacks are read through a ref so `save` keeps a stable identity across
69
+ // renders — it is typically handed to an `onBlur`, and a new function every render
70
+ // would re-bind every field on every keystroke.
71
+ //
72
+ // Updated in an effect rather than during render: React only sanctions writing a
73
+ // ref while rendering for one-time initialisation (as `idRef` above does), because
74
+ // a render that is discarded would otherwise leave the ref holding props that never
75
+ // committed. `save` runs from event handlers, which are always after commit, so the
76
+ // effect is early enough.
77
+ const optsRef = useRef(opts);
78
+ useEffect(() => {
79
+ optsRef.current = opts;
80
+ });
81
+ /** The in-flight or settled create. `null` means the record does not exist yet. */
82
+ const createdRef = useRef(null);
83
+ /** Tail of the write chain, so saves apply in the order the user made them. */
84
+ const queueRef = useRef(Promise.resolve());
85
+ const save = useCallback((patch) => {
86
+ const run = async () => {
87
+ const id = idRef.current;
88
+ const { create, update, onCreated } = optsRef.current;
89
+ if (createdRef.current === null) {
90
+ const attempt = (async () => {
91
+ await create(id, patch);
92
+ })();
93
+ createdRef.current = attempt;
94
+ try {
95
+ await attempt;
96
+ }
97
+ catch (error) {
98
+ // Unlatch. The record was not written, so the next save has to be free to
99
+ // create it — leaving the promise in place would send every later write to
100
+ // a row that does not exist.
101
+ createdRef.current = null;
102
+ throw error;
103
+ }
104
+ onCreated?.(id);
105
+ return;
106
+ }
107
+ await createdRef.current;
108
+ await update(id, patch);
109
+ };
110
+ // Chained on both settle paths: one failed save must not strand the ones behind
111
+ // it, but they still have to run in order.
112
+ const result = queueRef.current.then(run, run);
113
+ queueRef.current = result.catch(() => undefined);
114
+ return result;
115
+ }, []);
116
+ return { id: idRef.current, save };
117
+ }
package/docs/mutations.md CHANGED
@@ -539,3 +539,40 @@ This is the full read → mutate → reconcile loop: `useQuery` reads, `row.*` c
539
539
  one workflow per (table, field) you mutate — typed and narrow, never a generic
540
540
  `setField(any_field)` that hands the client write access to every field
541
541
  ([security](./security.md)).
542
+
543
+ ## Editing a record that does not exist yet: `useNewRecord`
544
+
545
+ A "new record" surface — a drawer, a detail page reached from a **New** button — has a problem
546
+ before its first write: it has no id. If the server mints one, then everything keyed on that id
547
+ (the route, the drawer, a list selection) *changes identity* the moment the first field saves,
548
+ and React answers an identity change by remounting the surface the user is typing into.
549
+
550
+ Mint the id on the client instead. `create_records` accepts caller-supplied `rec_*` ids, so the
551
+ id is known from the first render and the create stops being an event the UI has to survive:
552
+
553
+ ```tsx
554
+ const { id, save } = useNewRecord({
555
+ create: (id, patch) => createCustomer({ record_id: id, ...patch }),
556
+ update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
557
+ onCreated: list.refetch,
558
+ });
559
+
560
+ <InlineText onBlur={(name) => save({ name })} />
561
+ ```
562
+
563
+ - **The record is still created on the first write, not on mount** — a surface the user opens
564
+ and abandons leaves nothing behind.
565
+ - **`save` serialises.** The first call creates, every later one updates, and calls made before
566
+ the create resolves queue behind it. Two fields blurred in quick succession therefore produce
567
+ one record, not two. This is the whole reason to use the hook rather than a `useRef` latch:
568
+ hand-rolled, the racing-blur case creates a duplicate.
569
+ - **A failed create does not latch.** The next `save` retries the create, rather than updating a
570
+ row that was never written while the UI looks like it saved.
571
+ - `id` and `save` are stable across renders, so `save` can be bound directly to an `onBlur`.
572
+
573
+ Your `create` workflow must pass the id through to `create_records` as `ids: [record_id]`.
574
+ Creation is creation: an id that already exists is a conflict, never an overwrite — so the
575
+ workflow cannot be used to clobber another record by guessing its id.
576
+
577
+ Use `newRecordId()` directly if you need the id outside a hook (routing to the surface before
578
+ mounting it, say). It mints the same `rec_*` shape the server validates.
package/docs/queries.md CHANGED
@@ -431,7 +431,7 @@ derived surfaces share one implementation.
431
431
 
432
432
  | Field type | Column type | Source-layer operators | Derived-layer differences | Sort / group / aggregate notes |
433
433
  | --- | --- | --- | --- | --- |
434
- | text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). |
434
+ | text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
435
435
  | number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set | Full numeric aggregate set (§8). |
436
436
  | date / datetime | `date` / `datetime` (datetime when the field's format includes time) | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `time_of_day`, `is_empty`, `is_not_empty` — values are `DateTimePoint`s (below) | same set | Day-level filters on datetime values expand to the full-day window. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
437
437
  | boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
@@ -596,11 +596,20 @@ It AND-s with `filter` (search *within* a scope) and is templatable
596
596
  (`"search": "{{params.q}}"`). An empty or whitespace-only term matches everything; an
597
597
  unresolved optional token prunes to match-all (§6).
598
598
 
599
- **Search-as-you-type uses `search`, never a `contains` OR-group.** Per-field `contains` is
600
- accent-*sensitive* and unindexed — a zero-match keystroke forces a full-partition scan that
601
- hangs the picker. Reserve an OR-group of `contains` for when you must bound exactly *which*
602
- fields match. Gate the fetch on a non-empty term client-side (`enabled`), or first paint dumps
603
- the table.
599
+ **Search-as-you-type over a LARGE table uses `search`, never a `contains` OR-group.** Per-field
600
+ `contains` is unindexed — a zero-match keystroke forces a full-partition scan that hangs the
601
+ picker. (It is not accent-sensitive; the LIKE family folds diacritics, §5.) Gate the fetch on a
602
+ non-empty term client-side (`enabled`), or first paint dumps the table.
603
+
604
+ **But `search` matches the WHOLE record, and on an identity box that is usually wrong.** The
605
+ search document indexes select option labels, member names, dates in three formats, and each
606
+ linked record's cached display text — including the `Field=True/False` pill a boolean renders
607
+ into. A box captioned "find a person" then matches rows that merely share a stage label or an
608
+ owner, and folding makes near-homographs collide (`tiến` ≡ `tiền`). Measured on a real 88-row
609
+ customer book, one such term returned **every row**. When the box means *find this person*, bound
610
+ it: an OR-group of `contains` over the two or three identity fields (name, national id, phone),
611
+ with the term param `required: false` so an empty box prunes the group to match-all. Use `search`
612
+ when the box genuinely means *find this anywhere in the record*.
604
613
 
605
614
  ---
606
615
 
@@ -872,7 +881,9 @@ and is not — and a playbook rule applied to the wrong query costs effort while
872
881
  ### The authoring rules
873
882
 
874
883
  1. **Filter at the source.** Push every static predicate into `from_table.filter`.
875
- 2. **Prefer `search`** for any free-text box; `contains` OR-groups only to bound the fields.
884
+ 2. **Match the operator to what the box MEANS.** *Find this anywhere in the record* → `search`.
885
+ *Find this person/order* → a `contains` OR-group over the identity fields (§7) — the
886
+ whole-record index will surface rows that only share an owner or a status.
876
887
  3. **Aggregate and bucket server-side.** A dashboard reads grouped rows, never raw rows it
877
888
  reduces in JS — raw-row shipping burns the 10k cap, the timeout, and bandwidth at once.
878
889
  4. **Project narrow.** Every un-rendered column is wasted bytes; every un-rendered `files`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.63.2",
3
+ "version": "0.64.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {