@colixsystems/widget-sdk 0.281.0 → 0.284.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 CHANGED
@@ -51,6 +51,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
51
51
  | **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
52
52
  | **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". |
53
53
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*`. `options` is `{ filter, filterMode, q, sort, limit, offset, includeCapabilities, live }`, applied on the server: see *Querying records*. `live: true` re-reads when anyone else changes the table: see *Live lists*. |
54
+ | **DATASTORE** | `usePagedDatastoreQuery(table, { pageSize, ...query })` | `{ data, total, hasMore, loadingMore, error, loadMore, retry, … }` | A growing list, a page at a time: see *Querying records*. |
54
55
  | **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
55
56
  | **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
56
57
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
@@ -88,7 +89,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
88
89
 
89
90
  ## Status
90
91
 
91
- `v0.281.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
92
+ `v0.284.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
93
+
94
+ ### What's new in 0.284.0 (contract 1.233.0)
95
+
96
+ **A conversation can go when its last member leaves.** A `datastoreTemplate` table may declare `deleteWhenLastMemberLeaves: true`. Use it on a table whose rows people join and leave through per-record grants, such as conversations. When a signed-in member gives up their own grant and that leaves the row with none, and no table-wide grant lets anyone read the row, the row is deleted for good, together with every row a `CASCADE` relation ties to it (its messages, reactions and read states, including ones deleted earlier). A row that never had a grant, such as a seeded sample row, is never deleted this way, and a revoke made in the Studio never deletes anything. Each deletion is recorded in the table's history. An upgrade that declares it turns it on for the table already installed. The platform is rolling this out gradually, so a left row may stay in place for now.
97
+
98
+ ### What's new in 0.283.0 (contract 1.232.0)
99
+
100
+ **`usePagedDatastoreQuery(tableId, { pageSize, sort, filter, … })` — a list of records a page at a time.** A custom list read `useDatastoreQuery` with no `limit`, so it showed one server page and nothing said the rest existed. The new hook reads the first `pageSize` rows (default 25, 1-500), and `loadMore()` appends the next page by offset, so a tap reads one page instead of the whole list again. It returns `{ data, total, hasMore, loading, fetching, loadingMore, error, loadMore, retry, refetch }`: show a Load more `<Button busy={loadingMore}>` while `hasMore`. A failed page sets `error` and keeps the rows shown, and `retry()` fetches it again. A changed sort, filter, search or page size starts from the first page. A write, `live`, `refetch()` or pull-to-refresh re-reads every page shown. `PAGE_SIZE_PROPERTY` is the standard "Rows per page" property, the one the stock Data List shows: declare `pageSize: PAGE_SIZE_PROPERTY` in your `propertySchema` and pass the prop to the hook. The stock Data List now pages through this hook.
92
101
 
93
102
  ### What's new in 0.281.0 (contract 1.230.0)
94
103
 
@@ -3699,44 +3708,39 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
3699
3708
 
3700
3709
  The filter operators are `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains` (a case-insensitive substring), `empty` and `nempty` (no value). On a list column (`STRING_ARRAY`, `INT_ARRAY`), `eq` keeps the rows whose list holds the value exactly, `empty` / `nempty` test whether it holds any, and every other operator is refused. In the array form a condition may carry `valueMode`: `"currentUser"` or `"currentUserGroup"` with `eq` or `neq` matches the signed-in user (or their groups) on a USER or USER_GROUP column, and `"relativeDate"` with an ordering operator reads `value` as "that many days ago".
3701
3710
 
3702
- The result's `total` is how many rows match in all, not just this page: show "12 of 312" and page with `offset`. For a count, a sum or an average, use `useDatastoreFigure(tableId, { aggregate, column, filter })` instead of reading rows.
3711
+ The result's `total` is how many rows match in all, not just this page. **A list that grows reads its rows with `usePagedDatastoreQuery(tableId, { pageSize, ...query })`**, which takes the same options bar `limit` / `offset`, sets those itself, and appends the next page on `loadMore()`. Declare `pageSize: PAGE_SIZE_PROPERTY` in the `propertySchema` so the maker sets the rows per page. For a count, a sum or an average, use `useDatastoreFigure(tableId, { aggregate, column, filter })` instead of reading rows.
3703
3712
 
3704
3713
  **Narrow on the server, never over a fetched batch.** A widget that fetches `limit: 1000` and then filters or searches the rows in JavaScript only ever sees the first 1000; once the table grows past that, it misses rows without saying so. Send the search box's text as `q`, a chip's condition as `filter`, and page with `offset`. The checker warns about the batch pattern.
3705
3714
 
3706
3715
  ```js
3707
3716
  import { useEffect, useState } from "react";
3708
- import { Pressable, Text, TextInput, View, useDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3709
-
3710
- const PAGE = 50;
3717
+ import { Button, Pressable, Text, TextInput, View, usePagedDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3711
3718
 
3712
- export default function ContactList({ tableId }) {
3719
+ // manifest.propertySchema: { tableId: { type: "tableRef", … }, pageSize: PAGE_SIZE_PROPERTY }
3720
+ export default function ContactList({ tableId, pageSize }) {
3713
3721
  const [draft, setDraft] = useState("");
3714
3722
  const [search, setSearch] = useState("");
3715
3723
  const [status, setStatus] = useState("");
3716
- const [page, setPage] = useState(0);
3717
3724
  useEffect(() => {
3718
- const wait = setTimeout(() => { setSearch(draft.trim()); setPage(0); }, 250);
3725
+ const wait = setTimeout(() => setSearch(draft.trim()), 250);
3719
3726
  return () => clearTimeout(wait);
3720
3727
  }, [draft]);
3728
+ // A new search or status starts again from the first page.
3721
3729
  const query = useStableQuery(() => ({
3722
3730
  q: search,
3723
3731
  filter: status ? { status: `eq:${status}` } : undefined,
3724
3732
  sort: { field: "last_contacted", dir: "desc" },
3725
- limit: PAGE,
3726
- offset: page * PAGE,
3733
+ pageSize,
3727
3734
  }));
3728
- const { data, total, loading, error } = useDatastoreQuery(tableId, query);
3729
- const pick = (next) => { setStatus(next); setPage(0); };
3735
+ const { data, total, loading, error, hasMore, loadingMore, loadMore, retry } = usePagedDatastoreQuery(tableId, query);
3730
3736
  return (
3731
3737
  <View>
3732
3738
  <TextInput value={draft} onChangeText={setDraft} placeholder="Search" />
3733
- <Pressable onPress={() => pick(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3734
- {error ? <Text>{error.message}</Text> : null}
3739
+ <Pressable onPress={() => setStatus(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3735
3740
  {loading ? <Text>Loading…</Text> : data.map((row) => <Text key={row.id}>{row.name}</Text>)}
3741
+ {error ? <Button variant="ghost" title="Retry" onPress={retry} /> : null}
3736
3742
  <Text>{total ?? 0} contacts</Text>
3737
- {(page + 1) * PAGE < (total ?? 0) ? (
3738
- <Pressable onPress={() => setPage(page + 1)}><Text>Next page</Text></Pressable>
3739
- ) : null}
3743
+ {hasMore ? <Button variant="ghost" title="Load more" busy={loadingMore} onPress={loadMore} /> : null}
3740
3744
  </View>
3741
3745
  );
3742
3746
  }
package/dist/contract.cjs CHANGED
@@ -422,6 +422,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
422
422
  }),
423
423
  });
424
424
 
425
+ // sc-14120 — the "Rows per page" property a paged list declares, the same one
426
+ // the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
427
+ const PAGE_SIZE_PROPERTY = Object.freeze({
428
+ type: "number",
429
+ label: "Rows per page",
430
+ description: "How many records to load at first; a Load more control fetches the next batch.",
431
+ default: 25,
432
+ validation: Object.freeze({ min: 1, max: 500 }),
433
+ });
434
+
425
435
  // The card-surface field names shared by every widget that paints its own card
426
436
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
427
437
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -1992,6 +2002,42 @@ const HOOKS = [
1992
2002
  requiredContextSlice: ["datastore.records"],
1993
2003
  scopes: ["datastore.read:*"],
1994
2004
  },
2005
+ {
2006
+ name: "usePagedDatastoreQuery",
2007
+ signature: "usePagedDatastoreQuery(tableId, options)",
2008
+ optionalArgs: ["options"],
2009
+ description:
2010
+ "Reads a table's rows a page at a time, for any list of records that " +
2011
+ "can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
2012
+ "`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
2013
+ "`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
2014
+ "`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
2015
+ "the next page after the rows in `data`, so each tap reads one page, not " +
2016
+ "the whole list again. `hasMore` is false once every row the query " +
2017
+ "matches (`total`) is loaded. `loadingMore` is true while a page is " +
2018
+ "fetched: show the Load more button busy on it, and hide the button when " +
2019
+ "`hasMore` is false. A failed page sets `error` and keeps the rows " +
2020
+ "already shown; `retry()` fetches it again. A changed sort, filter, " +
2021
+ "search or pageSize starts again from the first page. A write to the " +
2022
+ "table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
2023
+ "Take the size from the maker's \"Rows per page\" property: declare " +
2024
+ "the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
2025
+ "PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
2026
+ returnShape: {
2027
+ data: "Record[]",
2028
+ total: "number | null",
2029
+ hasMore: "boolean",
2030
+ loading: "boolean",
2031
+ fetching: "boolean",
2032
+ loadingMore: "boolean",
2033
+ error: "DatastoreError | null",
2034
+ loadMore: "() => void",
2035
+ retry: "() => void",
2036
+ refetch: "() => Promise<void>",
2037
+ },
2038
+ requiredContextSlice: ["datastore.records"],
2039
+ scopes: ["datastore.read:*"],
2040
+ },
1995
2041
  {
1996
2042
  name: "useDatastoreFigure",
1997
2043
  signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
@@ -4105,7 +4151,7 @@ const MANIFEST_SCHEMA = {
4105
4151
  type: "object",
4106
4152
  required: false,
4107
4153
  description:
4108
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4154
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4109
4155
  },
4110
4156
  translations: {
4111
4157
  type: "object",
@@ -6667,7 +6713,13 @@ const CONTRACT = deepFreeze({
6667
6713
  // 1.230.0: additive (sc-15164) — each access plan carries
6668
6714
  // `purchase_available`; a store build buys a plan the workspace sells
6669
6715
  // in-app through the store, and web purchases grant RevenueCat entitlements.
6670
- version: "1.230.0",
6716
+ // 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
6717
+ // page at a time, with loadMore / hasMore / retry) and
6718
+ // `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
6719
+ // "Rows per page" property.
6720
+ // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6721
+ // `deleteWhenLastMemberLeaves: true`.
6722
+ version: "1.233.0",
6671
6723
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6672
6724
  hooks: HOOKS,
6673
6725
  primitives: PRIMITIVES,
@@ -6693,6 +6745,7 @@ const CONTRACT = deepFreeze({
6693
6745
  themeMenuTypes: THEME_MENU_TYPES,
6694
6746
  themeWidgetStyles: THEME_WIDGET_STYLES,
6695
6747
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6748
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6696
6749
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6697
6750
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6698
6751
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/contract.js CHANGED
@@ -418,6 +418,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
418
418
  }),
419
419
  });
420
420
 
421
+ // sc-14120 — the "Rows per page" property a paged list declares, the same one
422
+ // the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
423
+ const PAGE_SIZE_PROPERTY = Object.freeze({
424
+ type: "number",
425
+ label: "Rows per page",
426
+ description: "How many records to load at first; a Load more control fetches the next batch.",
427
+ default: 25,
428
+ validation: Object.freeze({ min: 1, max: 500 }),
429
+ });
430
+
421
431
  // The card-surface field names shared by every widget that paints its own card
422
432
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
423
433
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -1988,6 +1998,42 @@ const HOOKS = [
1988
1998
  requiredContextSlice: ["datastore.records"],
1989
1999
  scopes: ["datastore.read:*"],
1990
2000
  },
2001
+ {
2002
+ name: "usePagedDatastoreQuery",
2003
+ signature: "usePagedDatastoreQuery(tableId, options)",
2004
+ optionalArgs: ["options"],
2005
+ description:
2006
+ "Reads a table's rows a page at a time, for any list of records that " +
2007
+ "can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
2008
+ "`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
2009
+ "`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
2010
+ "`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
2011
+ "the next page after the rows in `data`, so each tap reads one page, not " +
2012
+ "the whole list again. `hasMore` is false once every row the query " +
2013
+ "matches (`total`) is loaded. `loadingMore` is true while a page is " +
2014
+ "fetched: show the Load more button busy on it, and hide the button when " +
2015
+ "`hasMore` is false. A failed page sets `error` and keeps the rows " +
2016
+ "already shown; `retry()` fetches it again. A changed sort, filter, " +
2017
+ "search or pageSize starts again from the first page. A write to the " +
2018
+ "table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
2019
+ "Take the size from the maker's \"Rows per page\" property: declare " +
2020
+ "the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
2021
+ "PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
2022
+ returnShape: {
2023
+ data: "Record[]",
2024
+ total: "number | null",
2025
+ hasMore: "boolean",
2026
+ loading: "boolean",
2027
+ fetching: "boolean",
2028
+ loadingMore: "boolean",
2029
+ error: "DatastoreError | null",
2030
+ loadMore: "() => void",
2031
+ retry: "() => void",
2032
+ refetch: "() => Promise<void>",
2033
+ },
2034
+ requiredContextSlice: ["datastore.records"],
2035
+ scopes: ["datastore.read:*"],
2036
+ },
1991
2037
  {
1992
2038
  name: "useDatastoreFigure",
1993
2039
  signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
@@ -4101,7 +4147,7 @@ const MANIFEST_SCHEMA = {
4101
4147
  type: "object",
4102
4148
  required: false,
4103
4149
  description:
4104
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4150
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4105
4151
  },
4106
4152
  translations: {
4107
4153
  type: "object",
@@ -6663,7 +6709,13 @@ const CONTRACT = deepFreeze({
6663
6709
  // 1.230.0: additive (sc-15164) — each access plan carries
6664
6710
  // `purchase_available`; a store build buys a plan the workspace sells
6665
6711
  // in-app through the store, and web purchases grant RevenueCat entitlements.
6666
- version: "1.230.0",
6712
+ // 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
6713
+ // page at a time, with loadMore / hasMore / retry) and
6714
+ // `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
6715
+ // "Rows per page" property.
6716
+ // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6717
+ // `deleteWhenLastMemberLeaves: true`.
6718
+ version: "1.233.0",
6667
6719
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6668
6720
  hooks: HOOKS,
6669
6721
  primitives: PRIMITIVES,
@@ -6689,6 +6741,7 @@ const CONTRACT = deepFreeze({
6689
6741
  themeMenuTypes: THEME_MENU_TYPES,
6690
6742
  themeWidgetStyles: THEME_WIDGET_STYLES,
6691
6743
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6744
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6692
6745
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6693
6746
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6694
6747
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/index.d.ts CHANGED
@@ -305,6 +305,14 @@ export interface WidgetDatastoreTemplateTable {
305
305
  * upgrade that declares it turns it on for the existing table.
306
306
  */
307
307
  presence?: boolean;
308
+ /**
309
+ * When true, a row is deleted for good, with every row a CASCADE relation
310
+ * ties to it, once a signed-in member giving up their own grant leaves it
311
+ * with none and no table-wide grant lets anyone read it. A row that never had a grant and a
312
+ * revoke made in the Studio never delete. An upgrade that declares it turns
313
+ * it on for the existing table.
314
+ */
315
+ deleteWhenLastMemberLeaves?: boolean;
308
316
  /**
309
317
  * REQ-TEMPLATES-ACL: optional table-level public grant. Omitted means
310
318
  * the table starts purely per-record (record-level grants are the only
@@ -1160,6 +1168,48 @@ export function useDatastoreQuery<T = unknown>(
1160
1168
  query?: Query,
1161
1169
  ): QueryResult<T>;
1162
1170
 
1171
+ export interface PagedQueryOptions extends Omit<Query, "limit" | "offset"> {
1172
+ /** Rows per page: default 25, clamped to 1-500. Pass the `pageSize` prop. */
1173
+ pageSize?: number;
1174
+ }
1175
+
1176
+ export interface PagedQueryResult<T> {
1177
+ /** Every row loaded so far, first page first. */
1178
+ data: T[];
1179
+ /** How many rows the query matches in all; null until known. */
1180
+ total: number | null;
1181
+ /** False once every matching row is loaded. Hide Load more on it. */
1182
+ hasMore: boolean;
1183
+ /** True only until the first page lands. */
1184
+ loading: boolean;
1185
+ /** True while any page, or a re-read of them, is in flight. */
1186
+ fetching: boolean;
1187
+ /** True while `loadMore()` fetches the next page. */
1188
+ loadingMore: boolean;
1189
+ /** A failed page; the rows already loaded stay in `data`. */
1190
+ error: Error | null;
1191
+ /** Appends the next page. Does nothing when `hasMore` is false. */
1192
+ loadMore(): void;
1193
+ /** Fetches the failed page again (or re-reads the list). */
1194
+ retry(): void;
1195
+ refetch(): Promise<void>;
1196
+ }
1197
+
1198
+ /**
1199
+ * A table's rows a page at a time: the first `pageSize` at once, the next on
1200
+ * each `loadMore()`. A changed sort, filter or page size starts again.
1201
+ */
1202
+ export function usePagedDatastoreQuery<T = unknown>(
1203
+ table: string | null | undefined,
1204
+ options?: PagedQueryOptions,
1205
+ ): PagedQueryResult<T>;
1206
+
1207
+ /**
1208
+ * The standard "Rows per page" property (number, default 25, 1-500), the one
1209
+ * the stock Data List shows. Declare it as `pageSize` in a list's propertySchema.
1210
+ */
1211
+ export const PAGE_SIZE_PROPERTY: Readonly<WidgetPropertyDef>;
1212
+
1163
1213
  export interface FigureOptions {
1164
1214
  /** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
1165
1215
  aggregate?: "count" | "sum" | "average";
package/dist/index.js CHANGED
@@ -136,6 +136,7 @@ export {
136
136
  // sc-15163 — native-only in-app purchase; the web Player brokers none.
137
137
  export { usePurchases, PurchasesError } from "./purchases.js";
138
138
  export { useDatastoreFigure } from "./datastore-figure.js";
139
+ export { usePagedDatastoreQuery, PAGE_SIZE_PROPERTY } from "./paged-query.js";
139
140
  export {
140
141
  useDatastoreAggregate,
141
142
  useUserStats,
@@ -134,6 +134,7 @@ export {
134
134
  // sc-15163 — native-only in-app purchase; the web Player brokers none.
135
135
  export { usePurchases, PurchasesError } from "./purchases.js";
136
136
  export { useDatastoreFigure } from "./datastore-figure.js";
137
+ export { usePagedDatastoreQuery, PAGE_SIZE_PROPERTY } from "./paged-query.js";
137
138
  export {
138
139
  useDatastoreAggregate,
139
140
  useUserStats,
@@ -0,0 +1,193 @@
1
+ // sc-14120 — a list of records read a page at a time, shared by the stock Data
2
+ // List and custom widgets on both hosts. The first page is useDatastoreQuery's,
3
+ // so it keeps that hook's stale-while-revalidate, write re-reads, `live` and
4
+ // pull-to-refresh; the pages after it are appended by offset. Platform-free,
5
+ // so the web and native entries export this one module.
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { CONTRACT } from "./contract.js";
8
+ import { toDatastoreError, useDatastoreQuery, useWidgetContextOrThrow } from "./hooks.js";
9
+
10
+ export const PAGE_SIZE_PROPERTY = CONTRACT.pageSizeProperty;
11
+
12
+ // REQ-QUERY-02: the server returns at most this many rows per request.
13
+ const MAX_ROWS_PER_READ = 1000;
14
+
15
+ const NO_PAGES = Object.freeze({ key: null, rows: Object.freeze([]), pages: 0, total: null });
16
+
17
+ /** A `pageSize` prop as the page size read: whole, within the property's bounds. */
18
+ export function normalisePageSize(value) {
19
+ const { min, max } = PAGE_SIZE_PROPERTY.validation;
20
+ const n = Math.floor(Number(value));
21
+ if (!Number.isFinite(n) || n < min) return PAGE_SIZE_PROPERTY.default;
22
+ return Math.min(n, max);
23
+ }
24
+
25
+ function windowKey(table, pageSize, query) {
26
+ try {
27
+ return JSON.stringify([table, pageSize, query]);
28
+ } catch (_e) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** Rows `[offset, offset + count)`, in requests the server's row cap allows. */
34
+ async function readRange(ns, query, offset, count) {
35
+ const reads = [];
36
+ for (let start = 0; start < count; start += MAX_ROWS_PER_READ) {
37
+ const limit = Math.min(MAX_ROWS_PER_READ, count - start);
38
+ reads.push(ns.list({ ...query, limit, offset: offset + start }));
39
+ }
40
+ const answers = await Promise.all(reads);
41
+ const rows = [];
42
+ let total = null;
43
+ for (const res of answers) {
44
+ if (res && Array.isArray(res.data)) rows.push(...res.data);
45
+ if (res && res.meta && Number.isFinite(res.meta.total)) total = res.meta.total;
46
+ }
47
+ return { rows, total };
48
+ }
49
+
50
+ // A row that moved into the next page between two reads shows once.
51
+ function withoutRepeats(first, more) {
52
+ if (more.length === 0) return first;
53
+ const seen = new Set();
54
+ const out = [];
55
+ for (const row of [...first, ...more]) {
56
+ const id = row && row.id;
57
+ if (id !== undefined && id !== null) {
58
+ if (seen.has(id)) continue;
59
+ seen.add(id);
60
+ }
61
+ out.push(row);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * A table's rows, `pageSize` at a time, with `loadMore()` appending the next
68
+ * page. See the usePagedDatastoreQuery CONTRACT entry.
69
+ */
70
+ export function usePagedDatastoreQuery(table, options) {
71
+ const ctx = useWidgetContextOrThrow("usePagedDatastoreQuery");
72
+ // `limit` and `offset` are the hook's to set, so a caller's are dropped.
73
+ // eslint-disable-next-line no-unused-vars
74
+ const { pageSize: requestedPageSize, limit, offset, ...query } = options || {};
75
+ const pageSize = normalisePageSize(requestedPageSize);
76
+ const boundTable = table || null;
77
+ const first = useDatastoreQuery(boundTable, { ...query, limit: pageSize });
78
+
79
+ const key = boundTable ? windowKey(boundTable, pageSize, query) : null;
80
+ const [more, setMore] = useState(NO_PAGES);
81
+ // The page read in flight or failed: { key, seq, pages, appending, error }.
82
+ const [op, setOp] = useState(null);
83
+ // A new query drops the old one's pages and reads before they can return.
84
+ const [windowOf, setWindowOf] = useState(key);
85
+ if (windowOf !== key) {
86
+ setWindowOf(key);
87
+ setMore(NO_PAGES);
88
+ setOp(null);
89
+ }
90
+
91
+ // Live inputs held in refs so loadMore / retry stay stable callbacks.
92
+ const live = useRef({});
93
+ live.current = {
94
+ key,
95
+ table: boundTable,
96
+ pageSize,
97
+ query,
98
+ records: ctx.datastore && ctx.datastore.records,
99
+ firstFetching: first.fetching,
100
+ more: more.key === key ? more : NO_PAGES,
101
+ op: op && op.key === key ? op : null,
102
+ };
103
+ const seqRef = useRef(0);
104
+ useEffect(() => {
105
+ seqRef.current += 1;
106
+ }, [key]);
107
+
108
+ // Reads pages 1..`pages` after the first one; `appending` reads only the last.
109
+ const readPages = useCallback(async (pages, appending) => {
110
+ const { key: k, table: t, pageSize: size, query: q, records, more: m } = live.current;
111
+ if (!k || !records) return;
112
+ const seq = ++seqRef.current;
113
+ setOp({ key: k, seq, pages, appending, error: null });
114
+ const from = appending ? pages : 1;
115
+ try {
116
+ const { rows, total } = await readRange(records(t), q, from * size, (pages - from + 1) * size);
117
+ if (seqRef.current !== seq || live.current.key !== k) return;
118
+ setMore({ key: k, rows: appending ? [...m.rows, ...rows] : rows, pages, total });
119
+ setOp(null);
120
+ } catch (err) {
121
+ if (seqRef.current !== seq || live.current.key !== k) return;
122
+ setOp({ key: k, seq, pages, appending, error: toDatastoreError(err) });
123
+ }
124
+ }, []);
125
+
126
+ const current = live.current.more;
127
+ const pending = live.current.op;
128
+ const total = first.total !== null ? first.total : current.total;
129
+ const loaded = current.pages === 0 ? first.data.length : pageSize + current.rows.length;
130
+ const lastPageFull = current.pages === 0 ? first.data.length >= pageSize : current.rows.length >= current.pages * pageSize;
131
+ const hasMore = Boolean(key) && !first.loading && !first.error && (total !== null ? loaded < total : lastPageFull);
132
+ live.current.hasMore = hasMore;
133
+
134
+ const loadMore = useCallback(() => {
135
+ const { key: k, more: m, op: o, hasMore: canLoad, firstFetching } = live.current;
136
+ if (!canLoad) return;
137
+ if (m.pages === 0 && firstFetching && !o) {
138
+ // The first page may still be the previous query's: wait for it.
139
+ setOp({ key: k, seq: ++seqRef.current, pages: 1, appending: true, queued: true, error: null });
140
+ return;
141
+ }
142
+ if (o && !o.error) {
143
+ // A page is already on its way; a re-read in flight takes one page more.
144
+ if (!o.appending) readPages(o.pages + 1, false);
145
+ return;
146
+ }
147
+ readPages(m.pages + 1, true);
148
+ }, [readPages]);
149
+
150
+ // A re-read of the first page (a write, `live`, refetch, pull-to-refresh)
151
+ // re-reads the pages after it, so the whole list shown stays current.
152
+ const firstRows = useRef(first.data);
153
+ useEffect(() => {
154
+ if (firstRows.current === first.data) return;
155
+ firstRows.current = first.data;
156
+ const { more: m, op: o } = live.current;
157
+ if (o && o.queued && m.pages === 0) {
158
+ readPages(1, true);
159
+ return;
160
+ }
161
+ const pages = Math.max(m.pages, o && !o.error ? o.pages : 0);
162
+ if (pages > 0) readPages(pages, false);
163
+ }, [first.data, readPages]);
164
+
165
+ const refetch = first.refetch;
166
+ const firstFailed = Boolean(first.error);
167
+ const firstFailedRef = useRef(firstFailed);
168
+ firstFailedRef.current = firstFailed;
169
+ const retry = useCallback(() => {
170
+ const o = live.current.op;
171
+ if (o && o.error) readPages(o.pages, o.appending);
172
+ if (firstFailedRef.current || !(o && o.error)) refetch();
173
+ }, [readPages, refetch]);
174
+
175
+ const data = useMemo(
176
+ () => withoutRepeats(first.data, current.rows),
177
+ [first.data, current.rows],
178
+ );
179
+ const waiting = Boolean(pending && !pending.error && !(pending.queued && firstFailed));
180
+ const loadingMore = waiting && pending.appending;
181
+ return {
182
+ data,
183
+ total,
184
+ hasMore,
185
+ loading: first.loading,
186
+ fetching: first.fetching || waiting,
187
+ loadingMore,
188
+ error: first.error || (pending && pending.error) || null,
189
+ loadMore,
190
+ retry,
191
+ refetch,
192
+ };
193
+ }
@@ -28,8 +28,10 @@ function stripNonCode(
28
28
  source,
29
29
  { keepStrings = false, keepTemplates = keepStrings } = {},
30
30
  ) {
31
- let out = "";
32
31
  const n = source.length;
32
+ // Code units, not `out += ch`: a per-character rope costs superlinear GC.
33
+ const out = new Uint16Array(n);
34
+ let written = 0;
33
35
  let mode = "code"; // code | line | block | sq | dq | tmpl
34
36
  // Brace depth, plus a stack of the depths at which an enclosing template
35
37
  // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
@@ -37,10 +39,10 @@ function stripNonCode(
37
39
  let braceDepth = 0;
38
40
  const tmplStack = [];
39
41
  const keep = (ch) => {
40
- out += ch;
42
+ out[written++] = ch.charCodeAt(0);
41
43
  };
42
44
  const blank = (ch) => {
43
- out += ch === "\n" || ch === "\r" ? ch : " ";
45
+ out[written++] = ch === "\n" || ch === "\r" ? ch.charCodeAt(0) : SPACE;
44
46
  };
45
47
  // String / template CONTENT: blanked for the banned-identifier scan
46
48
  // (prose must not trip `no-window`), kept for the host-API-URL scan,
@@ -162,7 +164,19 @@ function stripNonCode(
162
164
  }
163
165
  }
164
166
  }
165
- return out;
167
+ return decodeCodeUnits(out.subarray(0, written));
168
+ }
169
+
170
+ const SPACE = 0x20;
171
+ // fromCharCode spreads its arguments, so decode in stack-safe chunks.
172
+ const DECODE_CHUNK = 8192;
173
+
174
+ function decodeCodeUnits(units) {
175
+ const parts = [];
176
+ for (let at = 0; at < units.length; at += DECODE_CHUNK) {
177
+ parts.push(String.fromCharCode.apply(null, units.subarray(at, at + DECODE_CHUNK)));
178
+ }
179
+ return parts.join("");
166
180
  }
167
181
  module.exports = {
168
182
  stripNonCode,
@@ -24,8 +24,10 @@ export function stripNonCode(
24
24
  source,
25
25
  { keepStrings = false, keepTemplates = keepStrings } = {},
26
26
  ) {
27
- let out = "";
28
27
  const n = source.length;
28
+ // Code units, not `out += ch`: a per-character rope costs superlinear GC.
29
+ const out = new Uint16Array(n);
30
+ let written = 0;
29
31
  let mode = "code"; // code | line | block | sq | dq | tmpl
30
32
  // Brace depth, plus a stack of the depths at which an enclosing template
31
33
  // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
@@ -33,10 +35,10 @@ export function stripNonCode(
33
35
  let braceDepth = 0;
34
36
  const tmplStack = [];
35
37
  const keep = (ch) => {
36
- out += ch;
38
+ out[written++] = ch.charCodeAt(0);
37
39
  };
38
40
  const blank = (ch) => {
39
- out += ch === "\n" || ch === "\r" ? ch : " ";
41
+ out[written++] = ch === "\n" || ch === "\r" ? ch.charCodeAt(0) : SPACE;
40
42
  };
41
43
  // String / template CONTENT: blanked for the banned-identifier scan
42
44
  // (prose must not trip `no-window`), kept for the host-API-URL scan,
@@ -158,5 +160,17 @@ export function stripNonCode(
158
160
  }
159
161
  }
160
162
  }
161
- return out;
163
+ return decodeCodeUnits(out.subarray(0, written));
164
+ }
165
+
166
+ const SPACE = 0x20;
167
+ // fromCharCode spreads its arguments, so decode in stack-safe chunks.
168
+ const DECODE_CHUNK = 8192;
169
+
170
+ function decodeCodeUnits(units) {
171
+ const parts = [];
172
+ for (let at = 0; at < units.length; at += DECODE_CHUNK) {
173
+ parts.push(String.fromCharCode.apply(null, units.subarray(at, at + DECODE_CHUNK)));
174
+ }
175
+ return parts.join("");
162
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.281.0",
3
+ "version": "0.284.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -418,6 +418,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
418
418
  }),
419
419
  });
420
420
 
421
+ // sc-14120 — the "Rows per page" property a paged list declares, the same one
422
+ // the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
423
+ const PAGE_SIZE_PROPERTY = Object.freeze({
424
+ type: "number",
425
+ label: "Rows per page",
426
+ description: "How many records to load at first; a Load more control fetches the next batch.",
427
+ default: 25,
428
+ validation: Object.freeze({ min: 1, max: 500 }),
429
+ });
430
+
421
431
  // The card-surface field names shared by every widget that paints its own card
422
432
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
423
433
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -1988,6 +1998,42 @@ const HOOKS = [
1988
1998
  requiredContextSlice: ["datastore.records"],
1989
1999
  scopes: ["datastore.read:*"],
1990
2000
  },
2001
+ {
2002
+ name: "usePagedDatastoreQuery",
2003
+ signature: "usePagedDatastoreQuery(tableId, options)",
2004
+ optionalArgs: ["options"],
2005
+ description:
2006
+ "Reads a table's rows a page at a time, for any list of records that " +
2007
+ "can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
2008
+ "`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
2009
+ "`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
2010
+ "`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
2011
+ "the next page after the rows in `data`, so each tap reads one page, not " +
2012
+ "the whole list again. `hasMore` is false once every row the query " +
2013
+ "matches (`total`) is loaded. `loadingMore` is true while a page is " +
2014
+ "fetched: show the Load more button busy on it, and hide the button when " +
2015
+ "`hasMore` is false. A failed page sets `error` and keeps the rows " +
2016
+ "already shown; `retry()` fetches it again. A changed sort, filter, " +
2017
+ "search or pageSize starts again from the first page. A write to the " +
2018
+ "table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
2019
+ "Take the size from the maker's \"Rows per page\" property: declare " +
2020
+ "the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
2021
+ "PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
2022
+ returnShape: {
2023
+ data: "Record[]",
2024
+ total: "number | null",
2025
+ hasMore: "boolean",
2026
+ loading: "boolean",
2027
+ fetching: "boolean",
2028
+ loadingMore: "boolean",
2029
+ error: "DatastoreError | null",
2030
+ loadMore: "() => void",
2031
+ retry: "() => void",
2032
+ refetch: "() => Promise<void>",
2033
+ },
2034
+ requiredContextSlice: ["datastore.records"],
2035
+ scopes: ["datastore.read:*"],
2036
+ },
1991
2037
  {
1992
2038
  name: "useDatastoreFigure",
1993
2039
  signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
@@ -4101,7 +4147,7 @@ const MANIFEST_SCHEMA = {
4101
4147
  type: "object",
4102
4148
  required: false,
4103
4149
  description:
4104
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4150
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4105
4151
  },
4106
4152
  translations: {
4107
4153
  type: "object",
@@ -6663,7 +6709,13 @@ const CONTRACT = deepFreeze({
6663
6709
  // 1.230.0: additive (sc-15164) — each access plan carries
6664
6710
  // `purchase_available`; a store build buys a plan the workspace sells
6665
6711
  // in-app through the store, and web purchases grant RevenueCat entitlements.
6666
- version: "1.230.0",
6712
+ // 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
6713
+ // page at a time, with loadMore / hasMore / retry) and
6714
+ // `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
6715
+ // "Rows per page" property.
6716
+ // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6717
+ // `deleteWhenLastMemberLeaves: true`.
6718
+ version: "1.233.0",
6667
6719
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6668
6720
  hooks: HOOKS,
6669
6721
  primitives: PRIMITIVES,
@@ -6689,6 +6741,7 @@ const CONTRACT = deepFreeze({
6689
6741
  themeMenuTypes: THEME_MENU_TYPES,
6690
6742
  themeWidgetStyles: THEME_WIDGET_STYLES,
6691
6743
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6744
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6692
6745
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6693
6746
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6694
6747
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.