@colixsystems/widget-sdk 0.98.0 → 0.99.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 +69 -2
- package/dist/contract.cjs +83 -4
- package/dist/contract.js +83 -4
- package/dist/hooks.js +4659 -4344
- package/dist/index.d.ts +95 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/linter.cjs +68 -0
- package/dist/linter.js +68 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
6
6
|
|
|
7
7
|
| Injected at | Package | Surface (snake_case verbatim, `list` → `{ data, meta }`) |
|
|
8
8
|
| ----------- | ------- | -------- |
|
|
9
|
-
| `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
|
|
9
|
+
| `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `myPermissions(tableId, { recordId? })`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
|
|
10
10
|
| `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
|
|
11
11
|
| `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
|
|
12
12
|
| `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
|
|
@@ -38,12 +38,15 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
38
38
|
| **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
|
|
39
39
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
40
40
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
41
|
+
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
41
42
|
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
42
43
|
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
|
|
43
44
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
45
|
+
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
44
46
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
45
47
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
|
|
46
48
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
49
|
+
| **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "is this caller signed in AND permitted", reading the same table-ACL answer the write endpoint enforces. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
47
50
|
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
|
|
48
51
|
| **FILES** | `useAssetsByTag(tag, { type? })` | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
|
|
49
52
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
@@ -96,8 +99,18 @@ Host-integration surface only: no author-facing hook, prop, primitive, or manife
|
|
|
96
99
|
|
|
97
100
|
Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.
|
|
98
101
|
|
|
99
|
-
### What's new in 0.
|
|
102
|
+
### What's new in 0.98.0 (contract 1.70.0)
|
|
103
|
+
|
|
104
|
+
**Three new hooks close the biggest gaps in the write-gating and query-authoring surface (sc-5206).**
|
|
105
|
+
|
|
106
|
+
- **`useBoundColumns(tableId, shape, props)`** — resolve author-bound column NAMES from a widget's own props, built on `useDatastoreSchema`. Falls back name → case-insensitive name → first unclaimed column matching `dataType`, so `record[props.titleField]` reading `undefined` after a tenant renames a column is no longer a widget's problem: read `record[bound.titleField]` (via `const { columns: bound } = useBoundColumns(tableId, shape, props)`) and it keeps resolving.
|
|
107
|
+
- **`useStableQuery(buildQuery)`** — the same content-diffed stable-reference trick `useDatastoreQuery` already applies to its own `query` argument, generalised into a reusable hook: `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` and its easy-to-get-wrong deps array. Reads no `ctx` — safe outside a `WidgetContextProvider`.
|
|
108
|
+
- **`useCanWrite(tableId, options?)`** — a write-permission FLOOR reading a new `ctx.datastore.myPermissions` client method. `{ canWrite, loading, error, refetch }`; pass `{ recordId }` for a per-row check. Not a replacement for a MORE SPECIFIC domain rule (still hand-check "only the assigned user" in addition), and not a replacement for `useUser()` when the UI needs to tell "not signed in" apart from "signed in but forbidden".
|
|
100
109
|
|
|
110
|
+
The SDK linter gained two matching soft-warning rules: `raw-useMemo-into-datastore-query` (a `useDatastoreQuery` argument built from a raw `useMemo` when the file hasn't reached for `useStableQuery`) and `hand-rolled-write-gate` (a write gated on `useUser().groupIds`/`.roles` when the file hasn't reached for `useCanWrite`). Both are steering nudges (`severity: "warning"`), never publish-blocking.
|
|
111
|
+
|
|
112
|
+
- **`CONTRACT.version` → `1.70.0`** (additive: three new hooks + the new `datastore.myPermissions` context field + two linter rules). No existing export changed signature.
|
|
113
|
+
### What's new in 0.97.0 (contract 1.69.0)
|
|
101
114
|
**`CONTRACT.themeComponents` gains five scopes: `accent`, `destructive`, `muted`, `popover`, `ring` (sc-5392).** The vocabulary shipped with exactly `button`/`card`/`text` (sc-1497), so a shadcn/Tailwind app import's `--accent`, `--destructive`, `--muted`, `--popover` and `--ring` custom properties had no `themeConfig` home and were reported "no theme home" on every import. Each new scope binds to real `styleSchema` fields on the built-ins that already had a matching surface — `accent` (a highlight/tag surface: `background`/`borderColor`/`radius`) to the Label widget's own fields, `destructive` (a themed danger/delete action: `background`/`textColor`/`borderColor`) to a new "Danger" Button variant, `muted` (a subtle/secondary surface: `background`/`textColor`/`borderColor`/`radius`) to Form Input's and Form Builder's pre-existing input fields, `popover` (a dropdown/menu surface: `background`/`textColor`/`borderColor`) to the same two widgets' choice-field option list, and `ring` (the app-wide focus-visible outline: `color`/`width`) to a new emphasis border on Button. **This is HOST-ONLY plumbing, exactly like the three scopes before it** — `useTheme()`'s documented `components` slice is unchanged, no widget-authoring hook or `propertySchema` type moved, and no scope declares `universalFields`, so a third-party or AI-generated widget's contract is unaffected; the Developer guide and `DEFAULT_SYSTEM_PROMPT` need no update because neither ever documented this internal vocabulary. Fully additive: a theme with no `components` key, or one using only `button`/`card`/`text`, resolves exactly as before.
|
|
102
115
|
- **`CONTRACT.version` → `1.69.0`** (additive: five new `themeComponents` scopes + their target-field bindings). No existing scope, token, or export changed shape.
|
|
103
116
|
|
|
@@ -1032,6 +1045,60 @@ The manifest declares the matching scope:
|
|
|
1032
1045
|
|
|
1033
1046
|
The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
|
|
1034
1047
|
|
|
1048
|
+
## Resolving bound columns, a stable query, and a write-permission floor
|
|
1049
|
+
|
|
1050
|
+
`useBoundColumns`, `useStableQuery`, and `useCanWrite` (sc-5206) target the three most common ways a widget goes wrong against a table it doesn't fully control: a renamed column reading `undefined`, a query argument that refetches in a loop, and a write control offered to someone the table forbids.
|
|
1051
|
+
|
|
1052
|
+
```js
|
|
1053
|
+
import {
|
|
1054
|
+
Text,
|
|
1055
|
+
View,
|
|
1056
|
+
Pressable,
|
|
1057
|
+
useBoundColumns,
|
|
1058
|
+
useStableQuery,
|
|
1059
|
+
useDatastoreQuery,
|
|
1060
|
+
useCanWrite,
|
|
1061
|
+
useUser,
|
|
1062
|
+
} from "@colixsystems/widget-sdk";
|
|
1063
|
+
|
|
1064
|
+
export default function OpenTasks({ tableId, titleField, statusField }) {
|
|
1065
|
+
// Resolves to the CURRENT column names even if the author's binding is
|
|
1066
|
+
// stale (a column renamed after install), instead of reading `undefined`.
|
|
1067
|
+
const { columns: bound, loading: schemaLoading } = useBoundColumns(
|
|
1068
|
+
tableId,
|
|
1069
|
+
{ titleField: { dataType: "STRING" }, statusField: { dataType: "STRING" } },
|
|
1070
|
+
{ titleField, statusField },
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// A binding is undefined until the schema resolves, so hold the query back
|
|
1074
|
+
// rather than filtering on an undefined column name.
|
|
1075
|
+
const ready = Boolean(bound.statusField);
|
|
1076
|
+
// A stable query argument with no useMemo deps array to get wrong.
|
|
1077
|
+
const query = useStableQuery(() => ({
|
|
1078
|
+
filter: ready ? { [bound.statusField]: "eq:open" } : {},
|
|
1079
|
+
sort: { field: "created_at", dir: "desc" },
|
|
1080
|
+
}));
|
|
1081
|
+
const { data, loading } = useDatastoreQuery(ready ? tableId : null, query);
|
|
1082
|
+
|
|
1083
|
+
const user = useUser();
|
|
1084
|
+
const { canWrite } = useCanWrite(tableId);
|
|
1085
|
+
|
|
1086
|
+
if (schemaLoading || loading) return <Text>Loading…</Text>;
|
|
1087
|
+
return (
|
|
1088
|
+
<View>
|
|
1089
|
+
{data.map((row) => <Text key={row.id}>{row[bound.titleField]}</Text>)}
|
|
1090
|
+
{!user.id ? (
|
|
1091
|
+
<Text>Sign in to add a task</Text>
|
|
1092
|
+
) : canWrite ? (
|
|
1093
|
+
<Pressable onPress={() => {/* … */}}><Text>Add task</Text></Pressable>
|
|
1094
|
+
) : null /* signed in but not permitted — no control, not a disabled one */}
|
|
1095
|
+
</View>
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
`useCanWrite` answers "is this table's ACL open to me" — it is a FLOOR, not the whole rule. A widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition, typically with `options.recordId` passed to a second `useCanWrite` call or a plain `user.id === record[assigneeField]` comparison.
|
|
1101
|
+
|
|
1035
1102
|
## Cross-platform widgets (single-file vs split)
|
|
1036
1103
|
|
|
1037
1104
|
Every widget runs in **both** the web Player and the exported native (Expo) app.
|
package/dist/contract.cjs
CHANGED
|
@@ -510,6 +510,23 @@ const HOOKS = [
|
|
|
510
510
|
requiredContextSlice: ["i18n.locale"],
|
|
511
511
|
scopes: null,
|
|
512
512
|
},
|
|
513
|
+
{
|
|
514
|
+
name: "useStableQuery",
|
|
515
|
+
signature: "useStableQuery(buildQuery)",
|
|
516
|
+
description:
|
|
517
|
+
"sc-5206 — keep whatever buildQuery() returns at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so it can " +
|
|
518
|
+
"be passed straight into useDatastoreQuery's second argument (useDatastoreQuery(tableId, useStableQuery(() => ({...})))) instead of a hand-rolled " +
|
|
519
|
+
"useMemo with an easy-to-get-wrong deps array. This generalises the same trick useDatastoreQuery already applies internally to its own query " +
|
|
520
|
+
"argument. buildQuery is a ZERO-ARG function called every render; pure React state — no ctx, no host required, safe outside a " +
|
|
521
|
+
"WidgetContextProvider. Never throws: a buildQuery that itself throws degrades to a stable undefined, and a result JSON.stringify can't diff " +
|
|
522
|
+
"(e.g. a circular structure) degrades to \"always a new reference\".",
|
|
523
|
+
returnShape: {
|
|
524
|
+
"(returns)":
|
|
525
|
+
"T | undefined // the hook returns buildQuery()'s result directly, not a wrapper object",
|
|
526
|
+
},
|
|
527
|
+
requiredContextSlice: [],
|
|
528
|
+
scopes: null,
|
|
529
|
+
},
|
|
513
530
|
{
|
|
514
531
|
name: "useUser",
|
|
515
532
|
signature: "useUser()",
|
|
@@ -960,6 +977,29 @@ const HOOKS = [
|
|
|
960
977
|
requiredContextSlice: ["datastore.schema"],
|
|
961
978
|
scopes: ["datastore.read:<table>"],
|
|
962
979
|
},
|
|
980
|
+
{
|
|
981
|
+
name: "useBoundColumns",
|
|
982
|
+
signature: "useBoundColumns(tableId, shape, props)",
|
|
983
|
+
description:
|
|
984
|
+
"sc-5206 — resolve author-bound column NAMES from a widget's own props, built on useDatastoreSchema. shape is " +
|
|
985
|
+
"{ [propKey]: { dataType?, optional? } }; props is the widget's own props carrying author-bound column names (e.g. props.titleField === " +
|
|
986
|
+
"'Title'). Resolution order per key: (1) an exact name match on props[key], (2) a case-insensitive name match, (3) the first column whose " +
|
|
987
|
+
"data_type matches shape[key].dataType that no EARLIER key in this call already claimed, (4) unresolved. Unresolved NEVER throws — the key " +
|
|
988
|
+
"reads undefined, so a column an author renamed after install still resolves instead of crashing the widget. Returns { columns, resolved, " +
|
|
989
|
+
"missing, loading, error }: columns holds the resolved column NAME — a drop-in replacement for record[props.titleField] -> " +
|
|
990
|
+
"record[columns.titleField]; resolved holds the full Column object (snake_case verbatim, as useDatastoreSchema returns it) so a caller can " +
|
|
991
|
+
"inspect resolved.<key>.data_type; missing lists every key whose spec is not optional:true and did not resolve. Falsy tableId collapses to " +
|
|
992
|
+
"{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null } without a network round-trip.",
|
|
993
|
+
returnShape: {
|
|
994
|
+
columns: "{ [propKey]: string | undefined }",
|
|
995
|
+
resolved: "{ [propKey]: Column | undefined }",
|
|
996
|
+
missing: "string[] // required (non-optional) keys that did not resolve",
|
|
997
|
+
loading: "boolean",
|
|
998
|
+
error: "DatastoreError | null",
|
|
999
|
+
},
|
|
1000
|
+
requiredContextSlice: ["datastore.schema"],
|
|
1001
|
+
scopes: ["datastore.read:<table>"],
|
|
1002
|
+
},
|
|
963
1003
|
{
|
|
964
1004
|
name: "useInterpretDraft",
|
|
965
1005
|
signature: "useInterpretDraft(tableId)",
|
|
@@ -1331,6 +1371,28 @@ const HOOKS = [
|
|
|
1331
1371
|
requiredContextSlice: ["datastore.records"],
|
|
1332
1372
|
scopes: ["acl.write:records"],
|
|
1333
1373
|
},
|
|
1374
|
+
// sc-5206 — table/record write-permission floor, reading the injected
|
|
1375
|
+
// ctx.datastore.myPermissions.
|
|
1376
|
+
{
|
|
1377
|
+
name: "useCanWrite",
|
|
1378
|
+
signature: "useCanWrite(tableId, options?)",
|
|
1379
|
+
description:
|
|
1380
|
+
"sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
|
|
1381
|
+
"error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
|
|
1382
|
+
"domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
|
|
1383
|
+
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1384
|
+
"NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
|
|
1385
|
+
"tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
|
|
1386
|
+
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1387
|
+
returnShape: {
|
|
1388
|
+
canWrite: "boolean",
|
|
1389
|
+
loading: "boolean",
|
|
1390
|
+
error: "DatastoreError | null",
|
|
1391
|
+
refetch: "() => Promise<void>",
|
|
1392
|
+
},
|
|
1393
|
+
requiredContextSlice: ["datastore.myPermissions"],
|
|
1394
|
+
scopes: ["datastore.read:<table>"],
|
|
1395
|
+
},
|
|
1334
1396
|
// REQ-RT-07 — realtime table subscription.
|
|
1335
1397
|
{
|
|
1336
1398
|
name: "useDatastoreSubscription",
|
|
@@ -1862,13 +1924,19 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1862
1924
|
datastore: {
|
|
1863
1925
|
description:
|
|
1864
1926
|
"Injected @colixsystems/datastore-client instance. " +
|
|
1865
|
-
"{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
|
|
1927
|
+
"{ tables: { list(), get(idOrName), interpret(idOrName, body), myPermissions(idOrName, { recordId? }) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
|
|
1866
1928
|
"records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
|
|
1867
1929
|
"permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
|
|
1868
|
-
"`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
|
|
1930
|
+
"`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema() and useBoundColumns(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing); `myPermissions` backs useCanWrite() (sc-5206 — the caller's effective table/record write permission). " +
|
|
1869
1931
|
"List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
|
|
1870
1932
|
required: true,
|
|
1871
|
-
fields: {
|
|
1933
|
+
fields: {
|
|
1934
|
+
records: "function",
|
|
1935
|
+
schema: "function",
|
|
1936
|
+
tables: "object",
|
|
1937
|
+
interpret: "function",
|
|
1938
|
+
myPermissions: "function",
|
|
1939
|
+
},
|
|
1872
1940
|
},
|
|
1873
1941
|
directory: {
|
|
1874
1942
|
description:
|
|
@@ -3120,7 +3188,18 @@ const CONTRACT = deepFreeze({
|
|
|
3120
3188
|
// capped at five in TWO places -- a named constant on web and a bare `5`
|
|
3121
3189
|
// literal in the compiler -- so the number could drift between the hosts
|
|
3122
3190
|
// and no Studio surface could state it at all.
|
|
3123
|
-
|
|
3191
|
+
// 1.73.0: additive (sc-5206) — three new hooks: `useBoundColumns(tableId,
|
|
3192
|
+
// shape, props)` resolves author-bound column NAMES by name -> case-
|
|
3193
|
+
// insensitive name -> dataType fallback, so a renamed column still
|
|
3194
|
+
// resolves instead of the widget reading `record[props.titleField]`
|
|
3195
|
+
// directly and getting `undefined`; `useStableQuery(buildQuery)`
|
|
3196
|
+
// generalises `useDatastoreQuery`'s own content-diffed stable-reference
|
|
3197
|
+
// trick so a widget stops hand-rolling `useMemo` with a wrong deps
|
|
3198
|
+
// array; `useCanWrite(tableId, { recordId? })` reads a new
|
|
3199
|
+
// `ctx.datastore.myPermissions` client method as a write-permission
|
|
3200
|
+
// FLOOR, replacing the previous prompt-only "read useUser().groupIds /
|
|
3201
|
+
// .roles" guidance. No existing export changed signature.
|
|
3202
|
+
version: "1.73.0",
|
|
3124
3203
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3125
3204
|
hooks: HOOKS,
|
|
3126
3205
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -510,6 +510,23 @@ const HOOKS = [
|
|
|
510
510
|
requiredContextSlice: ["i18n.locale"],
|
|
511
511
|
scopes: null,
|
|
512
512
|
},
|
|
513
|
+
{
|
|
514
|
+
name: "useStableQuery",
|
|
515
|
+
signature: "useStableQuery(buildQuery)",
|
|
516
|
+
description:
|
|
517
|
+
"sc-5206 — keep whatever buildQuery() returns at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so it can " +
|
|
518
|
+
"be passed straight into useDatastoreQuery's second argument (useDatastoreQuery(tableId, useStableQuery(() => ({...})))) instead of a hand-rolled " +
|
|
519
|
+
"useMemo with an easy-to-get-wrong deps array. This generalises the same trick useDatastoreQuery already applies internally to its own query " +
|
|
520
|
+
"argument. buildQuery is a ZERO-ARG function called every render; pure React state — no ctx, no host required, safe outside a " +
|
|
521
|
+
"WidgetContextProvider. Never throws: a buildQuery that itself throws degrades to a stable undefined, and a result JSON.stringify can't diff " +
|
|
522
|
+
"(e.g. a circular structure) degrades to \"always a new reference\".",
|
|
523
|
+
returnShape: {
|
|
524
|
+
"(returns)":
|
|
525
|
+
"T | undefined // the hook returns buildQuery()'s result directly, not a wrapper object",
|
|
526
|
+
},
|
|
527
|
+
requiredContextSlice: [],
|
|
528
|
+
scopes: null,
|
|
529
|
+
},
|
|
513
530
|
{
|
|
514
531
|
name: "useUser",
|
|
515
532
|
signature: "useUser()",
|
|
@@ -960,6 +977,29 @@ const HOOKS = [
|
|
|
960
977
|
requiredContextSlice: ["datastore.schema"],
|
|
961
978
|
scopes: ["datastore.read:<table>"],
|
|
962
979
|
},
|
|
980
|
+
{
|
|
981
|
+
name: "useBoundColumns",
|
|
982
|
+
signature: "useBoundColumns(tableId, shape, props)",
|
|
983
|
+
description:
|
|
984
|
+
"sc-5206 — resolve author-bound column NAMES from a widget's own props, built on useDatastoreSchema. shape is " +
|
|
985
|
+
"{ [propKey]: { dataType?, optional? } }; props is the widget's own props carrying author-bound column names (e.g. props.titleField === " +
|
|
986
|
+
"'Title'). Resolution order per key: (1) an exact name match on props[key], (2) a case-insensitive name match, (3) the first column whose " +
|
|
987
|
+
"data_type matches shape[key].dataType that no EARLIER key in this call already claimed, (4) unresolved. Unresolved NEVER throws — the key " +
|
|
988
|
+
"reads undefined, so a column an author renamed after install still resolves instead of crashing the widget. Returns { columns, resolved, " +
|
|
989
|
+
"missing, loading, error }: columns holds the resolved column NAME — a drop-in replacement for record[props.titleField] -> " +
|
|
990
|
+
"record[columns.titleField]; resolved holds the full Column object (snake_case verbatim, as useDatastoreSchema returns it) so a caller can " +
|
|
991
|
+
"inspect resolved.<key>.data_type; missing lists every key whose spec is not optional:true and did not resolve. Falsy tableId collapses to " +
|
|
992
|
+
"{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null } without a network round-trip.",
|
|
993
|
+
returnShape: {
|
|
994
|
+
columns: "{ [propKey]: string | undefined }",
|
|
995
|
+
resolved: "{ [propKey]: Column | undefined }",
|
|
996
|
+
missing: "string[] // required (non-optional) keys that did not resolve",
|
|
997
|
+
loading: "boolean",
|
|
998
|
+
error: "DatastoreError | null",
|
|
999
|
+
},
|
|
1000
|
+
requiredContextSlice: ["datastore.schema"],
|
|
1001
|
+
scopes: ["datastore.read:<table>"],
|
|
1002
|
+
},
|
|
963
1003
|
{
|
|
964
1004
|
name: "useInterpretDraft",
|
|
965
1005
|
signature: "useInterpretDraft(tableId)",
|
|
@@ -1331,6 +1371,28 @@ const HOOKS = [
|
|
|
1331
1371
|
requiredContextSlice: ["datastore.records"],
|
|
1332
1372
|
scopes: ["acl.write:records"],
|
|
1333
1373
|
},
|
|
1374
|
+
// sc-5206 — table/record write-permission floor, reading the injected
|
|
1375
|
+
// ctx.datastore.myPermissions.
|
|
1376
|
+
{
|
|
1377
|
+
name: "useCanWrite",
|
|
1378
|
+
signature: "useCanWrite(tableId, options?)",
|
|
1379
|
+
description:
|
|
1380
|
+
"sc-5206 — is the signed-in caller permitted to write to tableId (or, with options.recordId, to that one row)? Returns { canWrite, loading, " +
|
|
1381
|
+
"error, refetch }, reading the injected ctx.datastore.myPermissions(tableId, { recordId }). This is a FLOOR, not a full replacement for " +
|
|
1382
|
+
"domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
|
|
1383
|
+
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1384
|
+
"NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
|
|
1385
|
+
"tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
|
|
1386
|
+
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1387
|
+
returnShape: {
|
|
1388
|
+
canWrite: "boolean",
|
|
1389
|
+
loading: "boolean",
|
|
1390
|
+
error: "DatastoreError | null",
|
|
1391
|
+
refetch: "() => Promise<void>",
|
|
1392
|
+
},
|
|
1393
|
+
requiredContextSlice: ["datastore.myPermissions"],
|
|
1394
|
+
scopes: ["datastore.read:<table>"],
|
|
1395
|
+
},
|
|
1334
1396
|
// REQ-RT-07 — realtime table subscription.
|
|
1335
1397
|
{
|
|
1336
1398
|
name: "useDatastoreSubscription",
|
|
@@ -1862,13 +1924,19 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1862
1924
|
datastore: {
|
|
1863
1925
|
description:
|
|
1864
1926
|
"Injected @colixsystems/datastore-client instance. " +
|
|
1865
|
-
"{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
|
|
1927
|
+
"{ tables: { list(), get(idOrName), interpret(idOrName, body), myPermissions(idOrName, { recordId? }) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
|
|
1866
1928
|
"records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
|
|
1867
1929
|
"permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
|
|
1868
|
-
"`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
|
|
1930
|
+
"`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema() and useBoundColumns(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing); `myPermissions` backs useCanWrite() (sc-5206 — the caller's effective table/record write permission). " +
|
|
1869
1931
|
"List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
|
|
1870
1932
|
required: true,
|
|
1871
|
-
fields: {
|
|
1933
|
+
fields: {
|
|
1934
|
+
records: "function",
|
|
1935
|
+
schema: "function",
|
|
1936
|
+
tables: "object",
|
|
1937
|
+
interpret: "function",
|
|
1938
|
+
myPermissions: "function",
|
|
1939
|
+
},
|
|
1872
1940
|
},
|
|
1873
1941
|
directory: {
|
|
1874
1942
|
description:
|
|
@@ -3120,7 +3188,18 @@ const CONTRACT = deepFreeze({
|
|
|
3120
3188
|
// capped at five in TWO places -- a named constant on web and a bare `5`
|
|
3121
3189
|
// literal in the compiler -- so the number could drift between the hosts
|
|
3122
3190
|
// and no Studio surface could state it at all.
|
|
3123
|
-
|
|
3191
|
+
// 1.73.0: additive (sc-5206) — three new hooks: `useBoundColumns(tableId,
|
|
3192
|
+
// shape, props)` resolves author-bound column NAMES by name -> case-
|
|
3193
|
+
// insensitive name -> dataType fallback, so a renamed column still
|
|
3194
|
+
// resolves instead of the widget reading `record[props.titleField]`
|
|
3195
|
+
// directly and getting `undefined`; `useStableQuery(buildQuery)`
|
|
3196
|
+
// generalises `useDatastoreQuery`'s own content-diffed stable-reference
|
|
3197
|
+
// trick so a widget stops hand-rolling `useMemo` with a wrong deps
|
|
3198
|
+
// array; `useCanWrite(tableId, { recordId? })` reads a new
|
|
3199
|
+
// `ctx.datastore.myPermissions` client method as a write-permission
|
|
3200
|
+
// FLOOR, replacing the previous prompt-only "read useUser().groupIds /
|
|
3201
|
+
// .roles" guidance. No existing export changed signature.
|
|
3202
|
+
version: "1.73.0",
|
|
3124
3203
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3125
3204
|
hooks: HOOKS,
|
|
3126
3205
|
primitives: PRIMITIVES,
|