@colixsystems/widget-sdk 0.223.0 → 0.227.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
@@ -84,7 +84,47 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.223.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**.
87
+ `v0.227.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**.
88
+
89
+ ### What's new in 0.227.0 (contract 1.183.0)
90
+
91
+ **Columns the server keeps, and rules every client meets.** A `datastoreTemplate` can now say what a widget used to write itself and the server could not check:
92
+
93
+ - `latestVia: "<relation column on the target that points back>"` on a ONE_TO_ONE / ONE_TO_MANY relation (with `onDelete` left at `SET_NULL`; the relation back must have `inheritAcl: true` and `immutable: true`) keeps it pointing at the newest related row — the newest message of a conversation.
94
+ - `copyOf: { via, column }` keeps a column equal to `column` on the row that latest-row column points at — the last message's text, time and author on the conversation row. Text copied into a STRING column is cut to 140 characters; a copy of an encrypted column must be `encrypted` too, and a copy cannot `inheritAcl`.
95
+
96
+ Both are written only by the server, in the same write as the related row, so a list reads who wrote last, and when, without trusting whoever wrote the parent row. A client value for them is ignored; read them back.
97
+
98
+ A table may also declare `constraints`, checked on every write and refused as `CONSTRAINT_VIOLATION`: `unique` (now with `unlessRelated`, waived for rows whose related row has a yes/no column set), `no-overlap`, `related-open` (no row is added, changed or removed while the related row's column is filled — votes on a closed poll) and `related-index` (a whole number that must be a position in the related row's list — a poll option). Additive.
99
+
100
+ ### What's new in 0.226.0 (contract 1.182.0)
101
+
102
+ **Rank by a column — `useDatastoreAggregate(tableId, { groupBy, order, limit })`.** Group a table by an ordinary column (text, number, yes/no, date or user) and get `groups: [{ group, count, sum }]`, counted on the server over every row the viewer can read, so it stays exact past the 1000-row page a list read returns. A user column's `group` is the user id, so a ranking is one call:
103
+
104
+ ```jsx
105
+ const { groups } = useDatastoreAggregate(props.tableId, {
106
+ groupBy: "Reporter",
107
+ order: "count:desc",
108
+ limit: 10,
109
+ filter: { Reporter: "nempty:" },
110
+ });
111
+ // groups: [{ group: "<user id>", count: 42, sum: 0 }, …]
112
+ ```
113
+
114
+ `order` is `count`, `sum` or `group`, then `:asc` or `:desc` (ascending when left out; by value when there is no `order`); `limit` is 1–1000. Rows with no value form one `Uncategorized` group. Encrypted, array, file and relation columns cannot be grouped.
115
+
116
+ ### What's new in 0.225.0 (contract 1.181.0)
117
+
118
+ **Chart a table over time — `useDatastoreAggregate(tableId, spec)`.** Counts rows per UTC day or ISO week (Monday) on the server, over every row the viewer may read, so the series stays exact past the 1000-row page a list read returns. Every bucket of the range comes back, empty ones as `count: 0`, so a chart has no gaps. `groupBy` is `"created_at"` (new rows, the default) or `"updated_at"` (changed rows); any other column is refused with a `VALIDATION` error. `sumColumn` adds up a plain number column per bucket. At most 31 buckets.
119
+
120
+ ```jsx
121
+ const { buckets, loading } = useDatastoreAggregate(props.tableId, { bucket: "week", from: twelveWeeksAgo });
122
+ // buckets: [{ date: "2026-09-28", count: 14, sum: 0 }, …]
123
+ ```
124
+
125
+ ### What's new in 0.224.0 (contract 1.180.0)
126
+
127
+ **A logged-out visitor can attach files to the row they just created.** On a table that lets everyone create rows, the create → `upload(file, { recordId: row.id })` → `update(row.id, { File: stored.id })` flow now works without signing in, for a few minutes after the create. The host carries a grant for that one row from the create to its upload and update, so widget code does not change. The visitor cannot edit the row's other fields, read the file back on a private table, attach more than ten files, or attach to any other row; editing an existing row's files still needs a signed-in user. Additive.
88
128
 
89
129
  ### What's new in 0.223.0 (contract 1.179.0)
90
130
 
package/dist/contract.cjs CHANGED
@@ -1397,7 +1397,8 @@ const HOOKS = [
1397
1397
  "create the row, call upload(file, { recordId: row.id }) — the per-call " +
1398
1398
  "recordId names the row the hook could not know at render — then write " +
1399
1399
  "the returned id into the FILE column with update(). A logged-out " +
1400
- "visitor cannot upload into a record space. For a file that belongs to " +
1400
+ "visitor may do this only for a row they just created, within a few " +
1401
+ "minutes, and never edits other rows' files. For a file that belongs to " +
1401
1402
  "no row: 'public' for content the app shows to anyone browsing, " +
1402
1403
  "'project' for signed-in workspace users only, 'personal' for a file " +
1403
1404
  "private to the uploading app user.",
@@ -1796,6 +1797,45 @@ const HOOKS = [
1796
1797
  requiredContextSlice: ["datastore.records"],
1797
1798
  scopes: ["datastore.read:*"],
1798
1799
  },
1800
+ // sc-10942 — rows per day or week, counted on the server over every row the
1801
+ // viewer may read. Date columns only until column grouping is exact too.
1802
+ {
1803
+ name: "useDatastoreAggregate",
1804
+ signature:
1805
+ "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1806
+ optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
1807
+ description:
1808
+ "Counts rows on the server over EVERY row the viewer can read, so it is " +
1809
+ "exact past the 1000-row page a list read returns — never group " +
1810
+ "useDatastoreQuery rows by hand. With `sumColumn` it also totals a plain " +
1811
+ "number column. BY DATE (`groupBy` \"created_at\", the default, or " +
1812
+ "\"updated_at\"): a SERIES for a chart, one entry per UTC day or ISO week " +
1813
+ "(Monday) in `buckets`; `bucket` is \"day\" (default) or \"week\"; " +
1814
+ "`from` / `to` are ISO dates (default: the 30 days, or 12 weeks, up to " +
1815
+ "now), at most 31 buckets, empty ones as count 0 so a chart has no gaps. " +
1816
+ "BY A COLUMN (any other `groupBy`: a text, number, yes/no, date or user " +
1817
+ "column): one entry per value in `groups`. A USER column's `group` is the " +
1818
+ "user id (resolve names with useDirectory({ ids })), so `order: \"count:desc\", " +
1819
+ "limit: 10` ranks the ten people with the most rows. `order` is count, sum " +
1820
+ "or group, then :asc or :desc (default: by value); `limit` is 1-1000. Rows " +
1821
+ "with no value form one \"Uncategorized\" group; on a user column, leave " +
1822
+ "them out with a `nempty:` filter. Encrypted, array, file and relation " +
1823
+ "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
1824
+ "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
1825
+ "table until a bound table is known.",
1826
+ returnShape: {
1827
+ buckets:
1828
+ "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
1829
+ groups:
1830
+ "Array<{ group, count, sum }> // by a column; a USER column's group is the user id",
1831
+ loading: "boolean",
1832
+ fetching: "boolean",
1833
+ error: "DatastoreError | null",
1834
+ refetch: "() => Promise<void>",
1835
+ },
1836
+ requiredContextSlice: ["datastore.records"],
1837
+ scopes: ["datastore.read:*"],
1838
+ },
1799
1839
  {
1800
1840
  name: "useDatastoreSchema",
1801
1841
  signature: "useDatastoreSchema(tableId)",
@@ -3563,7 +3603,7 @@ const MANIFEST_SCHEMA = {
3563
3603
  type: "object",
3564
3604
  required: false,
3565
3605
  description:
3566
- "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3606
+ "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3567
3607
  },
3568
3608
  translations: {
3569
3609
  type: "object",
@@ -5882,7 +5922,23 @@ const CONTRACT = deepFreeze({
5882
5922
  // `{ colors, onImage }`: the theme colours as they read over the picture.
5883
5923
  // 1.179.0: additive (sc-11253) — `useNavigation().goToStartPage()` opens
5884
5924
  // the signed-in user's own start page on both hosts.
5885
- version: "1.179.0",
5925
+ // 1.180.0: additive (sc-11244) — a logged-out visitor may attach files to
5926
+ // the row they just created, for a few minutes: the host carries a grant
5927
+ // from the create to that row's upload and update. Widget code is the
5928
+ // same create → upload → update flow.
5929
+ // 1.181.0: additive (sc-10942) — `useDatastoreAggregate(tableId, spec)`
5930
+ // counts rows per day or week on the server (date columns only), through
5931
+ // `records(t).aggregate`'s new bucket/from/to.
5932
+ // 1.182.0: additive (sc-10737) — `useDatastoreAggregate` groups by an
5933
+ // ordinary column too, into `groups` ([{ group, count, sum }]), with
5934
+ // `order` and `limit` for a top N; counted on the server over every
5935
+ // readable row. A user column groups by user id.
5936
+ // 1.183.0: additive (sc-10766) — a datastoreTemplate column may declare
5937
+ // `latestVia` (a server-kept pointer to the newest related row) or
5938
+ // `copyOf: { via, column }` (a server-kept copy read through one), and
5939
+ // a table may declare `constraints` (`unique` with `unlessRelated`,
5940
+ // `no-overlap`, `related-open`, `related-index`).
5941
+ version: "1.183.0",
5886
5942
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5887
5943
  hooks: HOOKS,
5888
5944
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1397,7 +1397,8 @@ const HOOKS = [
1397
1397
  "create the row, call upload(file, { recordId: row.id }) — the per-call " +
1398
1398
  "recordId names the row the hook could not know at render — then write " +
1399
1399
  "the returned id into the FILE column with update(). A logged-out " +
1400
- "visitor cannot upload into a record space. For a file that belongs to " +
1400
+ "visitor may do this only for a row they just created, within a few " +
1401
+ "minutes, and never edits other rows' files. For a file that belongs to " +
1401
1402
  "no row: 'public' for content the app shows to anyone browsing, " +
1402
1403
  "'project' for signed-in workspace users only, 'personal' for a file " +
1403
1404
  "private to the uploading app user.",
@@ -1796,6 +1797,45 @@ const HOOKS = [
1796
1797
  requiredContextSlice: ["datastore.records"],
1797
1798
  scopes: ["datastore.read:*"],
1798
1799
  },
1800
+ // sc-10942 — rows per day or week, counted on the server over every row the
1801
+ // viewer may read. Date columns only until column grouping is exact too.
1802
+ {
1803
+ name: "useDatastoreAggregate",
1804
+ signature:
1805
+ "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1806
+ optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
1807
+ description:
1808
+ "Counts rows on the server over EVERY row the viewer can read, so it is " +
1809
+ "exact past the 1000-row page a list read returns — never group " +
1810
+ "useDatastoreQuery rows by hand. With `sumColumn` it also totals a plain " +
1811
+ "number column. BY DATE (`groupBy` \"created_at\", the default, or " +
1812
+ "\"updated_at\"): a SERIES for a chart, one entry per UTC day or ISO week " +
1813
+ "(Monday) in `buckets`; `bucket` is \"day\" (default) or \"week\"; " +
1814
+ "`from` / `to` are ISO dates (default: the 30 days, or 12 weeks, up to " +
1815
+ "now), at most 31 buckets, empty ones as count 0 so a chart has no gaps. " +
1816
+ "BY A COLUMN (any other `groupBy`: a text, number, yes/no, date or user " +
1817
+ "column): one entry per value in `groups`. A USER column's `group` is the " +
1818
+ "user id (resolve names with useDirectory({ ids })), so `order: \"count:desc\", " +
1819
+ "limit: 10` ranks the ten people with the most rows. `order` is count, sum " +
1820
+ "or group, then :asc or :desc (default: by value); `limit` is 1-1000. Rows " +
1821
+ "with no value form one \"Uncategorized\" group; on a user column, leave " +
1822
+ "them out with a `nempty:` filter. Encrypted, array, file and relation " +
1823
+ "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
1824
+ "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
1825
+ "table until a bound table is known.",
1826
+ returnShape: {
1827
+ buckets:
1828
+ "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
1829
+ groups:
1830
+ "Array<{ group, count, sum }> // by a column; a USER column's group is the user id",
1831
+ loading: "boolean",
1832
+ fetching: "boolean",
1833
+ error: "DatastoreError | null",
1834
+ refetch: "() => Promise<void>",
1835
+ },
1836
+ requiredContextSlice: ["datastore.records"],
1837
+ scopes: ["datastore.read:*"],
1838
+ },
1799
1839
  {
1800
1840
  name: "useDatastoreSchema",
1801
1841
  signature: "useDatastoreSchema(tableId)",
@@ -3559,7 +3599,7 @@ const MANIFEST_SCHEMA = {
3559
3599
  type: "object",
3560
3600
  required: false,
3561
3601
  description:
3562
- "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3602
+ "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3563
3603
  },
3564
3604
  translations: {
3565
3605
  type: "object",
@@ -5878,7 +5918,23 @@ const CONTRACT = deepFreeze({
5878
5918
  // `{ colors, onImage }`: the theme colours as they read over the picture.
5879
5919
  // 1.179.0: additive (sc-11253) — `useNavigation().goToStartPage()` opens
5880
5920
  // the signed-in user's own start page on both hosts.
5881
- version: "1.179.0",
5921
+ // 1.180.0: additive (sc-11244) — a logged-out visitor may attach files to
5922
+ // the row they just created, for a few minutes: the host carries a grant
5923
+ // from the create to that row's upload and update. Widget code is the
5924
+ // same create → upload → update flow.
5925
+ // 1.181.0: additive (sc-10942) — `useDatastoreAggregate(tableId, spec)`
5926
+ // counts rows per day or week on the server (date columns only), through
5927
+ // `records(t).aggregate`'s new bucket/from/to.
5928
+ // 1.182.0: additive (sc-10737) — `useDatastoreAggregate` groups by an
5929
+ // ordinary column too, into `groups` ([{ group, count, sum }]), with
5930
+ // `order` and `limit` for a top N; counted on the server over every
5931
+ // readable row. A user column groups by user id.
5932
+ // 1.183.0: additive (sc-10766) — a datastoreTemplate column may declare
5933
+ // `latestVia` (a server-kept pointer to the newest related row) or
5934
+ // `copyOf: { via, column }` (a server-kept copy read through one), and
5935
+ // a table may declare `constraints` (`unique` with `unlessRelated`,
5936
+ // `no-overlap`, `related-open`, `related-index`).
5937
+ version: "1.183.0",
5882
5938
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5883
5939
  hooks: HOOKS,
5884
5940
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2706,7 +2706,8 @@ function isRetryableDomainStatus(status) {
2706
2706
  return status >= 500;
2707
2707
  }
2708
2708
 
2709
- function toDatastoreError(err) {
2709
+ // Internal: shared with stats-hooks.js, not re-exported by the index.
2710
+ export function toDatastoreError(err) {
2710
2711
  // An already-mapped SDK error passes straight through. NOTE: the datastore
2711
2712
  // CLIENT exports its own class of the same name, so a client rejection does
2712
2713
  // NOT match here — it is handled by the envelope read below, which is exactly
@@ -4804,7 +4805,8 @@ export function useFilestoreUpload(options) {
4804
4805
  setUploading(true);
4805
4806
  setError(null);
4806
4807
  try {
4807
- const created = await filesRef.current.upload(form);
4808
+ // sc-11244: names the row, so a visitor's grant for it can travel.
4809
+ const created = await filesRef.current.upload(form, isRecordSpace ? { recordId: ownerId } : undefined);
4808
4810
  setLastUploaded(created || null);
4809
4811
  setUploading(false);
4810
4812
  return created;
package/dist/index.d.ts CHANGED
@@ -228,8 +228,59 @@ export interface WidgetDatastoreTemplateColumn {
228
228
  * `MANY_TO_MANY` relation, whose links live outside the row.
229
229
  */
230
230
  immutable?: boolean;
231
+ /**
232
+ * ONE_TO_ONE / ONE_TO_MANY RELATION only, with `onDelete` left at
233
+ * `SET_NULL`: the server keeps this column pointing at the newest row of its
234
+ * target table whose relation column named here points back at this row.
235
+ * That relation must have `inheritAcl: true`, so only rows this row's
236
+ * readers can read are followed, and `immutable: true`, so a row never
237
+ * moves under another parent. Written only by the server; any value a
238
+ * client sends is ignored.
239
+ */
240
+ latestVia?: string;
241
+ /**
242
+ * The server keeps this column equal to `column` on the row the latest-row
243
+ * column `via` (on this table) points at: the same type, or text into
244
+ * text, cut to 140 characters in a STRING column. A copy of an encrypted
245
+ * column must be `encrypted` too, and a copy cannot `inheritAcl`. Written
246
+ * only by the server, so a list can show who wrote the newest row, and
247
+ * when, without trusting whoever wrote this one.
248
+ */
249
+ copyOf?: { via: string; column: string };
231
250
  }
232
251
 
252
+ /**
253
+ * A rule the server checks on every write to the table, for every client. A
254
+ * refused write is a `CONSTRAINT_VIOLATION`.
255
+ */
256
+ export type WidgetDatastoreTemplateConstraint =
257
+ | {
258
+ /** No two rows share these values; `unlessRelated` waives it for rows
259
+ * whose related row has that yes/no column set. */
260
+ type: "unique";
261
+ config: {
262
+ columns: string[];
263
+ partitionColumns?: string[];
264
+ unlessRelated?: { relation: string; column: string };
265
+ };
266
+ }
267
+ | {
268
+ /** No two rows' `startColumn`..`endColumn` intervals overlap. */
269
+ type: "no-overlap";
270
+ config: { startColumn: string; endColumn: string; partitionColumns?: string[] };
271
+ }
272
+ | {
273
+ /** No row is added, changed or removed while its related row's
274
+ * `closedColumn` is filled (a yes/no column: while it is yes). */
275
+ type: "related-open";
276
+ config: { relation: string; closedColumn: string };
277
+ }
278
+ | {
279
+ /** `column` must be a position (0-based) in the related row's list. */
280
+ type: "related-index";
281
+ config: { column: string; relation: string; listColumn: string };
282
+ };
283
+
233
284
  export interface WidgetDatastoreTemplateTable {
234
285
  /** Stable identifier within the template; appears in the created table name. */
235
286
  suffix: string;
@@ -267,6 +318,8 @@ export interface WidgetDatastoreTemplateTable {
267
318
  rows?: Array<
268
319
  Record<string, string | number | boolean | string[] | number[] | null>
269
320
  >;
321
+ /** Rules the server checks on every write, by column name. */
322
+ constraints?: WidgetDatastoreTemplateConstraint[];
270
323
  }
271
324
 
272
325
  /**
@@ -1048,6 +1101,65 @@ export function useDatastoreFigure(
1048
1101
  options?: FigureOptions,
1049
1102
  ): FigureResult;
1050
1103
 
1104
+ export interface AggregateBucketSpec {
1105
+ /** Bucket a system date column: `created_at` (default) or `updated_at`. */
1106
+ groupBy?: "created_at" | "updated_at";
1107
+ /** UTC days or ISO weeks (Monday); `day` by default. */
1108
+ bucket?: "day" | "week";
1109
+ /** ISO 8601. Defaults to 30 days (or 12 weeks) before `to`. */
1110
+ from?: string;
1111
+ /** ISO 8601. Defaults to now. At most 31 buckets in the range. */
1112
+ to?: string;
1113
+ /** A plain number column to add up per bucket. */
1114
+ sumColumn?: string;
1115
+ filter?: FigureOptions["filter"];
1116
+ }
1117
+
1118
+ export interface AggregateGroupSpec {
1119
+ /** A text, number, yes/no, date or user column. */
1120
+ groupBy: string;
1121
+ /** A plain number column to add up per group. */
1122
+ sumColumn?: string;
1123
+ filter?: FigureOptions["filter"];
1124
+ /** Default: by value. `count:desc` with a `limit` ranks the biggest groups. */
1125
+ order?: "count:desc" | "count:asc" | "sum:desc" | "sum:asc" | "group:asc" | "group:desc";
1126
+ /** 1–1000. */
1127
+ limit?: number;
1128
+ }
1129
+
1130
+ export interface AggregateBucket {
1131
+ /** The bucket's start date, `YYYY-MM-DD`. */
1132
+ date: string;
1133
+ count: number;
1134
+ /** 0 unless `sumColumn` was given. */
1135
+ sum: number;
1136
+ }
1137
+
1138
+ export interface AggregateGroup {
1139
+ /** The value; a user column's user id; "Uncategorized" for rows with none. */
1140
+ group: string;
1141
+ count: number;
1142
+ /** 0 unless `sumColumn` was given. */
1143
+ sum: number;
1144
+ }
1145
+
1146
+ export interface AggregateResult {
1147
+ /** By date: every bucket of the range, empty ones as `count: 0`. */
1148
+ buckets: AggregateBucket[];
1149
+ /** By a column: one entry per value. */
1150
+ groups: AggregateGroup[];
1151
+ loading: boolean;
1152
+ fetching: boolean;
1153
+ error: Error | null;
1154
+ refetch(): Promise<void>;
1155
+ }
1156
+
1157
+ /** Rows per day, week or column value over every row the viewer can read, counted on the server. */
1158
+ export function useDatastoreAggregate(
1159
+ table: string | null | undefined,
1160
+ spec?: AggregateBucketSpec | AggregateGroupSpec,
1161
+ ): AggregateResult;
1162
+
1051
1163
  export interface UserStatsQuery {
1052
1164
  days?: 7 | 30 | 90;
1053
1165
  /** A `useGroups()` row id: narrows user and invite figures to that group. */
@@ -1492,6 +1604,15 @@ export interface DatastoreSchemaColumn {
1492
1604
  display_column_id?: string | null;
1493
1605
  /** True when this column is the table's display/identification column. */
1494
1606
  is_identification?: boolean;
1607
+ /**
1608
+ * Set on a relation the server keeps at the newest related row; the id of
1609
+ * the related table's relation back. Clients cannot write such a column.
1610
+ */
1611
+ latest_via_column_id?: string | null;
1612
+ /** Set on a copy the server keeps: the latest-row column it reads through. */
1613
+ copy_via_column_id?: string | null;
1614
+ /** Set on a copy the server keeps: the related table's column it copies. */
1615
+ copy_of_column_id?: string | null;
1495
1616
  }
1496
1617
 
1497
1618
  export interface DatastoreSchema {
package/dist/index.js CHANGED
@@ -125,6 +125,7 @@ export {
125
125
  } from "./audio.js";
126
126
  export { useDatastoreFigure } from "./datastore-figure.js";
127
127
  export {
128
+ useDatastoreAggregate,
128
129
  useUserStats,
129
130
  useUserActivity,
130
131
  useActiveUsers,
@@ -123,6 +123,7 @@ export {
123
123
  } from "./audio.js";
124
124
  export { useDatastoreFigure } from "./datastore-figure.js";
125
125
  export {
126
+ useDatastoreAggregate,
126
127
  useUserStats,
127
128
  useUserActivity,
128
129
  useActiveUsers,
@@ -1,8 +1,16 @@
1
- // sc-10691 — read-only hooks for an app's user totals and user activity. Each
2
- // is one keyed read, stale-while-revalidate like useDatastoreQuery, and each
3
- // re-runs on the page's pull-to-refresh.
1
+ // sc-10691 — read-only aggregate hooks: record counts per day, week or column
2
+ // value, and an app's user totals. Both are one keyed read, stale-while-revalidate like
3
+ // useDatastoreQuery, and both re-run on the page's pull-to-refresh.
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
- import { toDirectoryError, useRefresh, useWidgetContextOrThrow } from "./hooks.js";
5
+ import {
6
+ toDatastoreError,
7
+ toDirectoryError,
8
+ useRefresh,
9
+ useWidgetContextOrThrow,
10
+ } from "./hooks.js";
11
+
12
+ // The system date columns bucket by day or week; any other column groups by value.
13
+ const DATE_GROUP_FIELDS = new Set(["created_at", "updated_at"]);
6
14
 
7
15
  const EMPTY = { identity: null, key: null, data: null, error: null };
8
16
 
@@ -66,6 +74,54 @@ function useKeyedRead(read, identity, key, mapError) {
66
74
  };
67
75
  }
68
76
 
77
+ /**
78
+ * Counts (and optionally sums) a table's rows on the server, over every row
79
+ * the viewer may read — not just one page. `spec`: `{ groupBy, sumColumn?,
80
+ * filter? }` plus, by date (`groupBy` "created_at" | "updated_at"), `bucket?:
81
+ * "day" | "week", from?, to?`, or, by a column, `order?` ("count:desc",
82
+ * "count:asc", "sum:desc", "sum:asc", "group:asc", "group:desc") and `limit?`
83
+ * (1–1000). Returns `{ buckets, groups, loading, fetching, error, refetch }`:
84
+ * by date, `buckets` is `[{ date, count, sum }]` with every bucket of the range
85
+ * present (empty ones as 0); by a column, `groups` is `[{ group, count, sum }]`,
86
+ * a user column's `group` being the user id and rows with no value one
87
+ * "Uncategorized" group. A falsy `table` settles to nothing.
88
+ */
89
+ export function useDatastoreAggregate(table, spec) {
90
+ const ctx = useWidgetContextOrThrow("useDatastoreAggregate");
91
+ if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
92
+ throw new Error("useDatastoreAggregate: host did not inject a datastore client");
93
+ }
94
+ const { groupBy = "created_at", bucket, from, to, sumColumn, filter, order, limit } = spec || {};
95
+ const byDate = DATE_GROUP_FIELDS.has(groupBy);
96
+ const key = table
97
+ ? serialize(byDate ? [groupBy, bucket, from, to, sumColumn, filter] : [groupBy, sumColumn, filter, order, limit])
98
+ : null;
99
+ const records = ctx.datastore.records;
100
+
101
+ const result = useKeyedRead(
102
+ async () => {
103
+ const rows = await records(table).aggregate(
104
+ byDate
105
+ ? { groupBy, bucket, from, to, sumField: sumColumn, filter }
106
+ : { groupBy, sumField: sumColumn, filter, order, limit },
107
+ );
108
+ return Array.isArray(rows) ? rows : [];
109
+ },
110
+ // The mode is part of the identity: switching it must not show the other's rows.
111
+ table ? `${table}|${byDate ? "date" : "column"}` : null,
112
+ key,
113
+ toDatastoreError,
114
+ );
115
+
116
+ const { data, ...state } = result;
117
+ const rows = data || [];
118
+ return {
119
+ ...state,
120
+ buckets: byDate ? rows.map((row) => ({ date: row.group, count: row.count, sum: row.sum })) : [],
121
+ groups: byDate ? [] : rows.map((row) => ({ group: row.group, count: row.count, sum: row.sum })),
122
+ };
123
+ }
124
+
69
125
  /**
70
126
  * The app's user totals for its own admins — never a list of people.
71
127
  * `query`: `{ days?: 7 | 30 | 90, group_id? }`. Returns `{ stats, loading,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.223.0",
3
+ "version": "0.227.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
@@ -1397,7 +1397,8 @@ const HOOKS = [
1397
1397
  "create the row, call upload(file, { recordId: row.id }) — the per-call " +
1398
1398
  "recordId names the row the hook could not know at render — then write " +
1399
1399
  "the returned id into the FILE column with update(). A logged-out " +
1400
- "visitor cannot upload into a record space. For a file that belongs to " +
1400
+ "visitor may do this only for a row they just created, within a few " +
1401
+ "minutes, and never edits other rows' files. For a file that belongs to " +
1401
1402
  "no row: 'public' for content the app shows to anyone browsing, " +
1402
1403
  "'project' for signed-in workspace users only, 'personal' for a file " +
1403
1404
  "private to the uploading app user.",
@@ -1796,6 +1797,45 @@ const HOOKS = [
1796
1797
  requiredContextSlice: ["datastore.records"],
1797
1798
  scopes: ["datastore.read:*"],
1798
1799
  },
1800
+ // sc-10942 — rows per day or week, counted on the server over every row the
1801
+ // viewer may read. Date columns only until column grouping is exact too.
1802
+ {
1803
+ name: "useDatastoreAggregate",
1804
+ signature:
1805
+ "useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
1806
+ optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
1807
+ description:
1808
+ "Counts rows on the server over EVERY row the viewer can read, so it is " +
1809
+ "exact past the 1000-row page a list read returns — never group " +
1810
+ "useDatastoreQuery rows by hand. With `sumColumn` it also totals a plain " +
1811
+ "number column. BY DATE (`groupBy` \"created_at\", the default, or " +
1812
+ "\"updated_at\"): a SERIES for a chart, one entry per UTC day or ISO week " +
1813
+ "(Monday) in `buckets`; `bucket` is \"day\" (default) or \"week\"; " +
1814
+ "`from` / `to` are ISO dates (default: the 30 days, or 12 weeks, up to " +
1815
+ "now), at most 31 buckets, empty ones as count 0 so a chart has no gaps. " +
1816
+ "BY A COLUMN (any other `groupBy`: a text, number, yes/no, date or user " +
1817
+ "column): one entry per value in `groups`. A USER column's `group` is the " +
1818
+ "user id (resolve names with useDirectory({ ids })), so `order: \"count:desc\", " +
1819
+ "limit: 10` ranks the ten people with the most rows. `order` is count, sum " +
1820
+ "or group, then :asc or :desc (default: by value); `limit` is 1-1000. Rows " +
1821
+ "with no value form one \"Uncategorized\" group; on a user column, leave " +
1822
+ "them out with a `nempty:` filter. Encrypted, array, file and relation " +
1823
+ "columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
1824
+ "as useDatastoreQuery's, created_at ranges included. Pass null as the " +
1825
+ "table until a bound table is known.",
1826
+ returnShape: {
1827
+ buckets:
1828
+ "Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
1829
+ groups:
1830
+ "Array<{ group, count, sum }> // by a column; a USER column's group is the user id",
1831
+ loading: "boolean",
1832
+ fetching: "boolean",
1833
+ error: "DatastoreError | null",
1834
+ refetch: "() => Promise<void>",
1835
+ },
1836
+ requiredContextSlice: ["datastore.records"],
1837
+ scopes: ["datastore.read:*"],
1838
+ },
1799
1839
  {
1800
1840
  name: "useDatastoreSchema",
1801
1841
  signature: "useDatastoreSchema(tableId)",
@@ -3559,7 +3599,7 @@ const MANIFEST_SCHEMA = {
3559
3599
  type: "object",
3560
3600
  required: false,
3561
3601
  description:
3562
- "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3602
+ "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: 8 tables, 24 columns per 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 8 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, 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` 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.",
3563
3603
  },
3564
3604
  translations: {
3565
3605
  type: "object",
@@ -5878,7 +5918,23 @@ const CONTRACT = deepFreeze({
5878
5918
  // `{ colors, onImage }`: the theme colours as they read over the picture.
5879
5919
  // 1.179.0: additive (sc-11253) — `useNavigation().goToStartPage()` opens
5880
5920
  // the signed-in user's own start page on both hosts.
5881
- version: "1.179.0",
5921
+ // 1.180.0: additive (sc-11244) — a logged-out visitor may attach files to
5922
+ // the row they just created, for a few minutes: the host carries a grant
5923
+ // from the create to that row's upload and update. Widget code is the
5924
+ // same create → upload → update flow.
5925
+ // 1.181.0: additive (sc-10942) — `useDatastoreAggregate(tableId, spec)`
5926
+ // counts rows per day or week on the server (date columns only), through
5927
+ // `records(t).aggregate`'s new bucket/from/to.
5928
+ // 1.182.0: additive (sc-10737) — `useDatastoreAggregate` groups by an
5929
+ // ordinary column too, into `groups` ([{ group, count, sum }]), with
5930
+ // `order` and `limit` for a top N; counted on the server over every
5931
+ // readable row. A user column groups by user id.
5932
+ // 1.183.0: additive (sc-10766) — a datastoreTemplate column may declare
5933
+ // `latestVia` (a server-kept pointer to the newest related row) or
5934
+ // `copyOf: { via, column }` (a server-kept copy read through one), and
5935
+ // a table may declare `constraints` (`unique` with `unlessRelated`,
5936
+ // `no-overlap`, `related-open`, `related-index`).
5937
+ version: "1.183.0",
5882
5938
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5883
5939
  hooks: HOOKS,
5884
5940
  primitives: PRIMITIVES,