@colixsystems/widget-sdk 0.280.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,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
88
89
 
89
90
  ## Status
90
91
 
91
- `v0.280.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.
97
+
98
+ ### What's new in 0.281.0 (contract 1.230.0)
99
+
100
+ **Paid page access in the store builds.** A workspace can sell a `useAccessPlans()` plan in its iOS and Android apps by naming a RevenueCat entitlement for it. In those builds `purchase(planId)` then opens the store's own in-app purchase sheet, and access arrives when RevenueCat confirms the purchase — call `refresh()` a moment later. Each plan now carries `purchase_available`; `purchaseAvailable` is `false` only when no plan can be bought here, so check the plan's own flag before showing its button. A plan or paid-app access bought on the web also grants its RevenueCat entitlement, so `usePurchases().entitlements` in the app includes it. `usePurchases()` itself stays native-only. The payments client's `purchaseAccessPlan()` may answer with `store_product_id` (`@colixsystems/payments-client` 0.4.0).
92
101
 
93
102
  ### What's new in 0.280.0 (contract 1.229.0)
94
103
 
@@ -3695,44 +3704,39 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
3695
3704
 
3696
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".
3697
3706
 
3698
- 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.
3699
3708
 
3700
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.
3701
3710
 
3702
3711
  ```js
3703
3712
  import { useEffect, useState } from "react";
3704
- import { Pressable, Text, TextInput, View, useDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3705
-
3706
- const PAGE = 50;
3713
+ import { Button, Pressable, Text, TextInput, View, usePagedDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
3707
3714
 
3708
- export default function ContactList({ tableId }) {
3715
+ // manifest.propertySchema: { tableId: { type: "tableRef", … }, pageSize: PAGE_SIZE_PROPERTY }
3716
+ export default function ContactList({ tableId, pageSize }) {
3709
3717
  const [draft, setDraft] = useState("");
3710
3718
  const [search, setSearch] = useState("");
3711
3719
  const [status, setStatus] = useState("");
3712
- const [page, setPage] = useState(0);
3713
3720
  useEffect(() => {
3714
- const wait = setTimeout(() => { setSearch(draft.trim()); setPage(0); }, 250);
3721
+ const wait = setTimeout(() => setSearch(draft.trim()), 250);
3715
3722
  return () => clearTimeout(wait);
3716
3723
  }, [draft]);
3724
+ // A new search or status starts again from the first page.
3717
3725
  const query = useStableQuery(() => ({
3718
3726
  q: search,
3719
3727
  filter: status ? { status: `eq:${status}` } : undefined,
3720
3728
  sort: { field: "last_contacted", dir: "desc" },
3721
- limit: PAGE,
3722
- offset: page * PAGE,
3729
+ pageSize,
3723
3730
  }));
3724
- const { data, total, loading, error } = useDatastoreQuery(tableId, query);
3725
- const pick = (next) => { setStatus(next); setPage(0); };
3731
+ const { data, total, loading, error, hasMore, loadingMore, loadMore, retry } = usePagedDatastoreQuery(tableId, query);
3726
3732
  return (
3727
3733
  <View>
3728
3734
  <TextInput value={draft} onChangeText={setDraft} placeholder="Search" />
3729
- <Pressable onPress={() => pick(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3730
- {error ? <Text>{error.message}</Text> : null}
3735
+ <Pressable onPress={() => setStatus(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3731
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}
3732
3738
  <Text>{total ?? 0} contacts</Text>
3733
- {(page + 1) * PAGE < (total ?? 0) ? (
3734
- <Pressable onPress={() => setPage(page + 1)}><Text>Next page</Text></Pressable>
3735
- ) : null}
3739
+ {hasMore ? <Button variant="ghost" title="Load more" busy={loadingMore} onPress={loadMore} /> : null}
3736
3740
  </View>
3737
3741
  );
3738
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 })",
@@ -2343,13 +2389,13 @@ const HOOKS = [
2343
2389
  signature: "useAccessPlans()",
2344
2390
  returnShape: {
2345
2391
  plans:
2346
- "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
2392
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2347
2393
  loading: "boolean",
2348
2394
  error: "PaymentError | null",
2349
2395
  purchaseAvailable:
2350
- "boolean // false in an iOS/Android build: page access is digital and needs store billing — render a visible not-available state, never a dead button or a blank",
2396
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2351
2397
  purchase:
2352
- "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
2398
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2353
2399
  cancel:
2354
2400
  "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2355
2401
  refresh: "() => Promise<void> // re-read after returning from Checkout",
@@ -3171,7 +3217,9 @@ const HOOKS = [
3171
3217
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3172
3218
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3173
3219
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3174
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3220
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3221
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3222
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3175
3223
  returnShape: {
3176
3224
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3177
3225
  loading: "boolean",
@@ -6662,7 +6710,14 @@ const CONTRACT = deepFreeze({
6662
6710
  // `kind` (physical | service | digital); three new PaymentError codes.
6663
6711
  // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6664
6712
  // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6665
- version: "1.229.0",
6713
+ // 1.230.0: additive (sc-15164) — each access plan carries
6714
+ // `purchase_available`; a store build buys a plan the workspace sells
6715
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
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",
6666
6721
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6667
6722
  hooks: HOOKS,
6668
6723
  primitives: PRIMITIVES,
@@ -6688,6 +6743,7 @@ const CONTRACT = deepFreeze({
6688
6743
  themeMenuTypes: THEME_MENU_TYPES,
6689
6744
  themeWidgetStyles: THEME_WIDGET_STYLES,
6690
6745
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6746
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6691
6747
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6692
6748
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6693
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 })",
@@ -2339,13 +2385,13 @@ const HOOKS = [
2339
2385
  signature: "useAccessPlans()",
2340
2386
  returnShape: {
2341
2387
  plans:
2342
- "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
2388
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2343
2389
  loading: "boolean",
2344
2390
  error: "PaymentError | null",
2345
2391
  purchaseAvailable:
2346
- "boolean // false in an iOS/Android build: page access is digital and needs store billing — render a visible not-available state, never a dead button or a blank",
2392
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2347
2393
  purchase:
2348
- "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
2394
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2349
2395
  cancel:
2350
2396
  "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2351
2397
  refresh: "() => Promise<void> // re-read after returning from Checkout",
@@ -3167,7 +3213,9 @@ const HOOKS = [
3167
3213
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3168
3214
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3169
3215
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3170
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3216
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3217
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3218
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3171
3219
  returnShape: {
3172
3220
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3173
3221
  loading: "boolean",
@@ -6658,7 +6706,14 @@ const CONTRACT = deepFreeze({
6658
6706
  // `kind` (physical | service | digital); three new PaymentError codes.
6659
6707
  // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6660
6708
  // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6661
- version: "1.229.0",
6709
+ // 1.230.0: additive (sc-15164) — each access plan carries
6710
+ // `purchase_available`; a store build buys a plan the workspace sells
6711
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
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",
6662
6717
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6663
6718
  hooks: HOOKS,
6664
6719
  primitives: PRIMITIVES,
@@ -6684,6 +6739,7 @@ const CONTRACT = deepFreeze({
6684
6739
  themeMenuTypes: THEME_MENU_TYPES,
6685
6740
  themeWidgetStyles: THEME_WIDGET_STYLES,
6686
6741
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6742
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6687
6743
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6688
6744
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6689
6745
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/hooks.js CHANGED
@@ -7075,21 +7075,24 @@ export function usePayments() {
7075
7075
  * `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }`:
7076
7076
  *
7077
7077
  * plans → [{ id, name, price_cents, currency, billing, owned, renews,
7078
- * access_ends_at }] — `billing` is "one_time" or "monthly"; `owned` is
7079
- * true once the user holds the plan; `renews` while a held monthly plan
7080
- * will charge again; `access_ends_at` is when held access ends without a
7081
- * renewal (null for one-time access).
7078
+ * access_ends_at, purchase_available }] — `billing` is "one_time" or
7079
+ * "monthly"; `owned` is true once the user holds the plan; `renews` while
7080
+ * a held monthly plan will charge again; `access_ends_at` is when held
7081
+ * access ends without a renewal (null for one-time access);
7082
+ * `purchase_available` is whether this plan can be bought here.
7082
7083
  * Render the price with `useWorkspaceCurrency().formatMoney(price_cents)`.
7083
- * purchaseAvailable → false where buying cannot work here: page access is a
7084
- * digital good, and an iOS / Android build must use store billing. Render a
7085
- * visible "not available in this app" state then — never a dead button and
7086
- * never a blank render.
7084
+ * purchaseAvailable → false where no plan can be bought here: page access is
7085
+ * a digital good, and an iOS / Android build sells only the plans the
7086
+ * workspace sells in-app (through store billing). Render a visible "not
7087
+ * available in this app" state for a plan whose `purchase_available` is
7088
+ * false — never a dead button and never a blank render.
7087
7089
  * purchase(planId, { return_path? }?) → Promise<{ plan_id, status }>. Pass
7088
7090
  * ONLY the plan id: the server sets the price and the group, so a widget can
7089
- * never name an amount. The HOST opens hosted Checkout (same-tab on web, the
7090
- * in-app browser on native). `status` is "ACTIVE" when access was granted at
7091
- * once (the mock provider) and "PENDING" while payment is confirmed; access
7092
- * arrives with the confirmation, never from the client.
7091
+ * never name an amount. The HOST takes the payment: hosted Checkout on the
7092
+ * web, the store's in-app purchase sheet in an iOS / Android build. `status`
7093
+ * is "ACTIVE" when access was granted at once (the mock provider) and
7094
+ * "PENDING" while payment is confirmed; access arrives with the
7095
+ * confirmation, never from the client.
7093
7096
  * cancel(planId) → Promise<{ plan_id, renews: false, access_ends_at }> —
7094
7097
  * stop a held monthly plan renewing (`renews` true). Access continues
7095
7098
  * until `access_ends_at`; offer it beside every renewing plan.
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";
@@ -1527,6 +1569,8 @@ export interface AccessPlan {
1527
1569
  renews: boolean;
1528
1570
  /** ISO time held access ends without a renewal; null for one-time access. */
1529
1571
  access_ends_at: string | null;
1572
+ /** Whether this plan can be bought here (false in a store build that does not sell it in-app). */
1573
+ purchase_available: boolean;
1530
1574
  }
1531
1575
 
1532
1576
  export interface AccessPlanPurchase {
@@ -1540,8 +1584,8 @@ export interface AccessPlansApi {
1540
1584
  loading: boolean;
1541
1585
  error: PaymentError | null;
1542
1586
  /**
1543
- * False where buying cannot work here (an iOS / Android build, where page
1544
- * access must use store billing): render a visible not-available state.
1587
+ * False where no plan can be bought here (an iOS / Android build sells only
1588
+ * the plans the workspace sells in-app): render a visible not-available state.
1545
1589
  */
1546
1590
  purchaseAvailable: boolean;
1547
1591
  /** Pass only the plan id; the server sets price and group. */
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.280.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 })",
@@ -2339,13 +2385,13 @@ const HOOKS = [
2339
2385
  signature: "useAccessPlans()",
2340
2386
  returnShape: {
2341
2387
  plans:
2342
- "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
2388
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2343
2389
  loading: "boolean",
2344
2390
  error: "PaymentError | null",
2345
2391
  purchaseAvailable:
2346
- "boolean // false in an iOS/Android build: page access is digital and needs store billing — render a visible not-available state, never a dead button or a blank",
2392
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2347
2393
  purchase:
2348
- "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
2394
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2349
2395
  cancel:
2350
2396
  "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2351
2397
  refresh: "() => Promise<void> // re-read after returning from Checkout",
@@ -3167,7 +3213,9 @@ const HOOKS = [
3167
3213
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3168
3214
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3169
3215
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3170
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3216
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3217
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3218
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3171
3219
  returnShape: {
3172
3220
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3173
3221
  loading: "boolean",
@@ -6658,7 +6706,14 @@ const CONTRACT = deepFreeze({
6658
6706
  // `kind` (physical | service | digital); three new PaymentError codes.
6659
6707
  // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6660
6708
  // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6661
- version: "1.229.0",
6709
+ // 1.230.0: additive (sc-15164) — each access plan carries
6710
+ // `purchase_available`; a store build buys a plan the workspace sells
6711
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
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",
6662
6717
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6663
6718
  hooks: HOOKS,
6664
6719
  primitives: PRIMITIVES,
@@ -6684,6 +6739,7 @@ const CONTRACT = deepFreeze({
6684
6739
  themeMenuTypes: THEME_MENU_TYPES,
6685
6740
  themeWidgetStyles: THEME_WIDGET_STYLES,
6686
6741
  basicStyleSchema: BASIC_STYLE_SCHEMA,
6742
+ pageSizeProperty: PAGE_SIZE_PROPERTY,
6687
6743
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6688
6744
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6689
6745
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.