@colixsystems/widget-sdk 0.281.0 → 0.283.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,11 @@ 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.283.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.283.0 (contract 1.232.0)
95
+
96
+ **`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
97
 
93
98
  ### What's new in 0.281.0 (contract 1.230.0)
94
99
 
@@ -3699,44 +3704,39 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
3699
3704
 
3700
3705
  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
3706
 
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.
3707
+ 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
3708
 
3704
3709
  **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
3710
 
3706
3711
  ```js
3707
3712
  import { useEffect, useState } from "react";
3708
- import { Pressable, Text, TextInput, View, useDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3713
+ import { Button, Pressable, Text, TextInput, View, usePagedDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3709
3714
 
3710
- const PAGE = 50;
3711
-
3712
- export default function ContactList({ tableId }) {
3715
+ // manifest.propertySchema: { tableId: { type: "tableRef", … }, pageSize: PAGE_SIZE_PROPERTY }
3716
+ export default function ContactList({ tableId, pageSize }) {
3713
3717
  const [draft, setDraft] = useState("");
3714
3718
  const [search, setSearch] = useState("");
3715
3719
  const [status, setStatus] = useState("");
3716
- const [page, setPage] = useState(0);
3717
3720
  useEffect(() => {
3718
- const wait = setTimeout(() => { setSearch(draft.trim()); setPage(0); }, 250);
3721
+ const wait = setTimeout(() => setSearch(draft.trim()), 250);
3719
3722
  return () => clearTimeout(wait);
3720
3723
  }, [draft]);
3724
+ // A new search or status starts again from the first page.
3721
3725
  const query = useStableQuery(() => ({
3722
3726
  q: search,
3723
3727
  filter: status ? { status: `eq:${status}` } : undefined,
3724
3728
  sort: { field: "last_contacted", dir: "desc" },
3725
- limit: PAGE,
3726
- offset: page * PAGE,
3729
+ pageSize,
3727
3730
  }));
3728
- const { data, total, loading, error } = useDatastoreQuery(tableId, query);
3729
- const pick = (next) => { setStatus(next); setPage(0); };
3731
+ const { data, total, loading, error, hasMore, loadingMore, loadMore, retry } = usePagedDatastoreQuery(tableId, query);
3730
3732
  return (
3731
3733
  <View>
3732
3734
  <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}
3735
+ <Pressable onPress={() => setStatus(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3735
3736
  {loading ? <Text>Loading…</Text> : data.map((row) => <Text key={row.id}>{row.name}</Text>)}
3737
+ {error ? <Button variant="ghost" title="Retry" onPress={retry} /> : null}
3736
3738
  <Text>{total ?? 0} contacts</Text>
3737
- {(page + 1) * PAGE < (total ?? 0) ? (
3738
- <Pressable onPress={() => setPage(page + 1)}><Text>Next page</Text></Pressable>
3739
- ) : null}
3739
+ {hasMore ? <Button variant="ghost" title="Load more" busy={loadingMore} onPress={loadMore} /> : null}
3740
3740
  </View>
3741
3741
  );
3742
3742
  }
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 })",
@@ -6667,7 +6713,11 @@ 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
+ version: "1.232.0",
6671
6721
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6672
6722
  hooks: HOOKS,
6673
6723
  primitives: PRIMITIVES,
@@ -6693,6 +6743,7 @@ const CONTRACT = deepFreeze({
6693
6743
  themeMenuTypes: THEME_MENU_TYPES,
6694
6744
  themeWidgetStyles: THEME_WIDGET_STYLES,
6695
6745
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6746
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6696
6747
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6697
6748
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6698
6749
  // 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 })",
@@ -6663,7 +6709,11 @@ 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
+ version: "1.232.0",
6667
6717
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6668
6718
  hooks: HOOKS,
6669
6719
  primitives: PRIMITIVES,
@@ -6689,6 +6739,7 @@ const CONTRACT = deepFreeze({
6689
6739
  themeMenuTypes: THEME_MENU_TYPES,
6690
6740
  themeWidgetStyles: THEME_WIDGET_STYLES,
6691
6741
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6742
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6692
6743
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6693
6744
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6694
6745
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/index.d.ts CHANGED
@@ -1160,6 +1160,48 @@ export function useDatastoreQuery<T = unknown>(
1160
1160
  query?: Query,
1161
1161
  ): QueryResult<T>;
1162
1162
 
1163
+ export interface PagedQueryOptions extends Omit<Query, "limit" | "offset"> {
1164
+ /** Rows per page: default 25, clamped to 1-500. Pass the `pageSize` prop. */
1165
+ pageSize?: number;
1166
+ }
1167
+
1168
+ export interface PagedQueryResult<T> {
1169
+ /** Every row loaded so far, first page first. */
1170
+ data: T[];
1171
+ /** How many rows the query matches in all; null until known. */
1172
+ total: number | null;
1173
+ /** False once every matching row is loaded. Hide Load more on it. */
1174
+ hasMore: boolean;
1175
+ /** True only until the first page lands. */
1176
+ loading: boolean;
1177
+ /** True while any page, or a re-read of them, is in flight. */
1178
+ fetching: boolean;
1179
+ /** True while `loadMore()` fetches the next page. */
1180
+ loadingMore: boolean;
1181
+ /** A failed page; the rows already loaded stay in `data`. */
1182
+ error: Error | null;
1183
+ /** Appends the next page. Does nothing when `hasMore` is false. */
1184
+ loadMore(): void;
1185
+ /** Fetches the failed page again (or re-reads the list). */
1186
+ retry(): void;
1187
+ refetch(): Promise<void>;
1188
+ }
1189
+
1190
+ /**
1191
+ * A table's rows a page at a time: the first `pageSize` at once, the next on
1192
+ * each `loadMore()`. A changed sort, filter or page size starts again.
1193
+ */
1194
+ export function usePagedDatastoreQuery<T = unknown>(
1195
+ table: string | null | undefined,
1196
+ options?: PagedQueryOptions,
1197
+ ): PagedQueryResult<T>;
1198
+
1199
+ /**
1200
+ * The standard "Rows per page" property (number, default 25, 1-500), the one
1201
+ * the stock Data List shows. Declare it as `pageSize` in a list's propertySchema.
1202
+ */
1203
+ export const PAGE_SIZE_PROPERTY: Readonly<WidgetPropertyDef>;
1204
+
1163
1205
  export interface FigureOptions {
1164
1206
  /** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
1165
1207
  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.283.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 })",
@@ -6663,7 +6709,11 @@ 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
+ version: "1.232.0",
6667
6717
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6668
6718
  hooks: HOOKS,
6669
6719
  primitives: PRIMITIVES,
@@ -6689,6 +6739,7 @@ const CONTRACT = deepFreeze({
6689
6739
  themeMenuTypes: THEME_MENU_TYPES,
6690
6740
  themeWidgetStyles: THEME_WIDGET_STYLES,
6691
6741
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6742
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6692
6743
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6693
6744
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6694
6745
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.