@colixsystems/widget-sdk 0.98.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -385,6 +385,27 @@ export interface DatastoreClient {
385
385
  get(tableIdOrName: string): Promise<unknown>;
386
386
  };
387
387
  schema(tableId: string): Promise<unknown>;
388
+ /**
389
+ * sc-5206 — REQ-ACL-COMPOSER-GATING: the caller's effective table-level
390
+ * (+ optional per-record) permissions. Backs `useCanWrite`.
391
+ */
392
+ myPermissions(
393
+ tableId: string,
394
+ options?: { recordId?: string },
395
+ ): Promise<{
396
+ can_read_schema: boolean;
397
+ can_create: boolean;
398
+ can_read_records: boolean;
399
+ can_read: boolean;
400
+ can_write: boolean;
401
+ can_delete: boolean;
402
+ record?: {
403
+ can_read: boolean;
404
+ can_write: boolean;
405
+ can_delete: boolean;
406
+ can_grant: boolean;
407
+ };
408
+ }>;
388
409
  records(tableId: string): {
389
410
  list(query?: Query): Promise<ListEnvelope>;
390
411
  get(recordId: string): Promise<unknown>;
@@ -971,6 +992,52 @@ export function useDatastoreSchema(
971
992
  tableId: string | null | undefined,
972
993
  ): SchemaResult;
973
994
 
995
+ /** sc-5206 — one prop key `useBoundColumns` resolves to a schema column. */
996
+ export interface BoundColumnSpec {
997
+ dataType?: string | string[];
998
+ /** When true, an unresolved key is omitted from `missing`. */
999
+ optional?: boolean;
1000
+ }
1001
+
1002
+ export type BoundColumnShape = Record<string, BoundColumnSpec>;
1003
+
1004
+ export interface BoundColumnsResult {
1005
+ /** Resolved column NAME per key — drop-in for `record[props.titleField]`. */
1006
+ columns: Record<string, string | undefined>;
1007
+ /** The full Column object per key, as `useDatastoreSchema` returns it. */
1008
+ resolved: Record<string, DatastoreSchemaColumn | undefined>;
1009
+ /** Keys whose spec is not `optional: true` and did not resolve. */
1010
+ missing: string[];
1011
+ loading: boolean;
1012
+ error: DatastoreError | null;
1013
+ }
1014
+
1015
+ /**
1016
+ * sc-5206 — resolve author-bound column NAMES from a widget's own props,
1017
+ * built on `useDatastoreSchema`. Falls back name -> case-insensitive name ->
1018
+ * first unclaimed column matching `dataType`, so a column an author renamed
1019
+ * after install still resolves. Never throws for a missing column — an
1020
+ * unresolved key reads `undefined` and, when not `optional: true`, is named
1021
+ * in `missing`. Falsy `tableId` collapses to `{ columns: {}, resolved: {},
1022
+ * missing: Object.keys(shape), loading: false, error: null }`.
1023
+ */
1024
+ export function useBoundColumns<TProps = Record<string, unknown>>(
1025
+ tableId: string | null | undefined,
1026
+ shape: BoundColumnShape,
1027
+ props: TProps,
1028
+ ): BoundColumnsResult;
1029
+
1030
+ /**
1031
+ * sc-5206 — keep whatever `buildQuery()` returns at a STABLE reference across
1032
+ * renders when its (JSON-serialised) content hasn't changed, so it can be
1033
+ * passed straight into `useDatastoreQuery`'s second argument instead of a
1034
+ * hand-rolled `useMemo` with an easy-to-get-wrong deps array. Pure React
1035
+ * state — no host / WidgetContext required. Never throws: a `buildQuery`
1036
+ * that throws degrades to a stable `undefined`; a result that can't be
1037
+ * diffed (e.g. circular) degrades to "always a new reference".
1038
+ */
1039
+ export function useStableQuery<T = unknown>(buildQuery: () => T): T | undefined;
1040
+
974
1041
  /** sc-4932 — one field a draft may target, with a dropdown's closed option set. */
975
1042
  export interface InterpretDraftField {
976
1043
  column: string;
@@ -1852,6 +1919,34 @@ export function useRecordPermissions(
1852
1919
  recordId: string | null | undefined,
1853
1920
  ): RecordPermissionsResult;
1854
1921
 
1922
+ export interface CanWriteOptions {
1923
+ recordId?: string;
1924
+ }
1925
+
1926
+ export interface CanWriteResult {
1927
+ canWrite: boolean;
1928
+ loading: boolean;
1929
+ error: DatastoreError | null;
1930
+ refetch(): Promise<void>;
1931
+ }
1932
+
1933
+ /**
1934
+ * sc-5206 — is the signed-in caller permitted to write to `tableId` (or, with
1935
+ * `options.recordId`, to that one row)? A FLOOR, not a full replacement for
1936
+ * domain-specific write rules: a widget whose rule is more specific than the
1937
+ * table ACL (e.g. "only the assigned user may edit this row") must still
1938
+ * hand-check that in addition to this hook. Answers "signed in AND
1939
+ * permitted" — pair with `useUser()` to also tell "not signed in" apart from
1940
+ * "signed in but forbidden". Falsy `tableId`, or a host that has not
1941
+ * injected `ctx.datastore.myPermissions`, collapses to `{ canWrite: false,
1942
+ * loading: false, error: null, refetch: async () => undefined }` rather than
1943
+ * throwing.
1944
+ */
1945
+ export function useCanWrite(
1946
+ tableId: string | null | undefined,
1947
+ options?: CanWriteOptions,
1948
+ ): CanWriteResult;
1949
+
1855
1950
  export function WidgetContextProvider(props: {
1856
1951
  value: WidgetContext;
1857
1952
  children?: ReactNode;
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useBoundColumns,
19
+ useStableQuery,
20
+ useCanWrite,
18
21
  useInterpretDraft,
19
22
  useAsset,
20
23
  useAssetsByTag,
@@ -15,6 +15,9 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useBoundColumns,
19
+ useStableQuery,
20
+ useCanWrite,
18
21
  useInterpretDraft,
19
22
  useAsset,
20
23
  useAssetsByTag,
package/dist/linter.cjs CHANGED
@@ -944,6 +944,71 @@ function _datastoreErrorHandlingRules(source) {
944
944
  ];
945
945
  }
946
946
 
947
+ // sc-5206 — soft warning: a `useDatastoreQuery` argument built from a raw
948
+ // `const q = useMemo(() => ({...}), [...])` re-fetches in a loop the moment
949
+ // the deps array is wrong (sc-1579's volatile-query gate exists because of
950
+ // exactly this mistake). Flags only when the file has NOT already reached
951
+ // for `useStableQuery`, the hook that removes the deps array entirely.
952
+ function _rawUseMemoIntoDatastoreQueryRules(source) {
953
+ const code = _stripNonCode(source);
954
+ if (/\buseStableQuery\s*\(/.test(code)) return [];
955
+ const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
956
+ const findings = [];
957
+ const sourceLines = source.split(/\r?\n/);
958
+ const seen = new Set();
959
+ let m;
960
+ while ((m = callRe.exec(code))) {
961
+ const varName = m[1];
962
+ if (seen.has(varName)) continue;
963
+ const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
964
+ const memoMatch = memoRe.exec(code);
965
+ if (!memoMatch || memoMatch.index >= m.index) continue;
966
+ seen.add(varName);
967
+ const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
968
+ findings.push({
969
+ rule: "raw-useMemo-into-datastore-query",
970
+ severity: "warning",
971
+ label:
972
+ `Pass useStableQuery(() => ({...})) as the query argument instead ` +
973
+ `of hand-rolling useMemo — it removes the wrong-deps-array failure ` +
974
+ `mode.`,
975
+ line,
976
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
977
+ });
978
+ }
979
+ return findings;
980
+ }
981
+
982
+ // sc-5206 — soft warning, the write-gate twin of `write-not-gated-on-user`.
983
+ // A widget that hand-derives write permission from `useUser().groupIds` /
984
+ // `.roles` re-implements the table ACL client-side and drifts from it the
985
+ // moment the ACL changes; `useCanWrite(tableId)` reads the same rule the
986
+ // write endpoint enforces. Flags only when the file has NOT already reached
987
+ // for it.
988
+ function _handRolledWriteGateRules(source) {
989
+ const code = _stripNonCode(source);
990
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
991
+ const userCall = /\buseUser\s*\(\s*\)/.exec(code);
992
+ if (!userCall) return [];
993
+ if (!/\.(groupIds|roles)\b/.test(code)) return [];
994
+ const hasWrite =
995
+ /\.(create|update)\s*\(/.test(code) ||
996
+ /\buseDatastoreMutation\s*\(/.test(code);
997
+ if (!hasWrite) return [];
998
+ const line = code.slice(0, userCall.index).split(/\r?\n/).length;
999
+ return [
1000
+ {
1001
+ rule: "hand-rolled-write-gate",
1002
+ severity: "warning",
1003
+ label:
1004
+ `Gate this write on useCanWrite(tableId) as the permission FLOOR — ` +
1005
+ `IN ADDITION TO (never instead of) a narrower roles/groupIds rule.`,
1006
+ line,
1007
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1008
+ },
1009
+ ];
1010
+ }
1011
+
947
1012
  function _imagePercentHeightRules(source) {
948
1013
  const findings = [];
949
1014
  const code = _stripNonCode(source, { keepStrings: true });
@@ -1113,6 +1178,9 @@ function lintSource(source, options) {
1113
1178
  findings.push(..._hardcodedCurrencyLabelRules(source));
1114
1179
  findings.push(..._paymentErrorHandlingRules(source));
1115
1180
  findings.push(..._datastoreErrorHandlingRules(source));
1181
+ // sc-5206 — soft warnings steering a widget toward the two new hooks.
1182
+ findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1183
+ findings.push(..._handRolledWriteGateRules(source));
1116
1184
  findings.push(
1117
1185
  ..._scopeRules(source, options && options.manifest).map((f) => ({
1118
1186
  ...f,
package/dist/linter.js CHANGED
@@ -1077,6 +1077,71 @@ function _datastoreErrorHandlingRules(source) {
1077
1077
  ];
1078
1078
  }
1079
1079
 
1080
+ // sc-5206 — soft warning: a `useDatastoreQuery` argument built from a raw
1081
+ // `const q = useMemo(() => ({...}), [...])` re-fetches in a loop the moment
1082
+ // the deps array is wrong (sc-1579's volatile-query gate exists because of
1083
+ // exactly this mistake). Flags only when the file has NOT already reached
1084
+ // for `useStableQuery`, the hook that removes the deps array entirely.
1085
+ function _rawUseMemoIntoDatastoreQueryRules(source) {
1086
+ const code = _stripNonCode(source);
1087
+ if (/\buseStableQuery\s*\(/.test(code)) return [];
1088
+ const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
1089
+ const findings = [];
1090
+ const sourceLines = source.split(/\r?\n/);
1091
+ const seen = new Set();
1092
+ let m;
1093
+ while ((m = callRe.exec(code))) {
1094
+ const varName = m[1];
1095
+ if (seen.has(varName)) continue;
1096
+ const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
1097
+ const memoMatch = memoRe.exec(code);
1098
+ if (!memoMatch || memoMatch.index >= m.index) continue;
1099
+ seen.add(varName);
1100
+ const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
1101
+ findings.push({
1102
+ rule: "raw-useMemo-into-datastore-query",
1103
+ severity: "warning",
1104
+ label:
1105
+ `Pass useStableQuery(() => ({...})) as the query argument instead ` +
1106
+ `of hand-rolling useMemo — it removes the wrong-deps-array failure ` +
1107
+ `mode.`,
1108
+ line,
1109
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1110
+ });
1111
+ }
1112
+ return findings;
1113
+ }
1114
+
1115
+ // sc-5206 — soft warning, the write-gate twin of `write-not-gated-on-user`.
1116
+ // A widget that hand-derives write permission from `useUser().groupIds` /
1117
+ // `.roles` re-implements the table ACL client-side and drifts from it the
1118
+ // moment the ACL changes; `useCanWrite(tableId)` reads the same rule the
1119
+ // write endpoint enforces. Flags only when the file has NOT already reached
1120
+ // for it.
1121
+ function _handRolledWriteGateRules(source) {
1122
+ const code = _stripNonCode(source);
1123
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
1124
+ const userCall = /\buseUser\s*\(\s*\)/.exec(code);
1125
+ if (!userCall) return [];
1126
+ if (!/\.(groupIds|roles)\b/.test(code)) return [];
1127
+ const hasWrite =
1128
+ /\.(create|update)\s*\(/.test(code) ||
1129
+ /\buseDatastoreMutation\s*\(/.test(code);
1130
+ if (!hasWrite) return [];
1131
+ const line = code.slice(0, userCall.index).split(/\r?\n/).length;
1132
+ return [
1133
+ {
1134
+ rule: "hand-rolled-write-gate",
1135
+ severity: "warning",
1136
+ label:
1137
+ `Gate this write on useCanWrite(tableId) as the permission FLOOR — ` +
1138
+ `IN ADDITION TO (never instead of) a narrower roles/groupIds rule.`,
1139
+ line,
1140
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1141
+ },
1142
+ ];
1143
+ }
1144
+
1080
1145
  function _imagePercentHeightRules(source) {
1081
1146
  const findings = [];
1082
1147
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -1276,6 +1341,9 @@ export function lintSource(source, options) {
1276
1341
  findings.push(..._hardcodedCurrencyLabelRules(source));
1277
1342
  findings.push(..._paymentErrorHandlingRules(source));
1278
1343
  findings.push(..._datastoreErrorHandlingRules(source));
1344
+ // sc-5206 — soft warnings steering a widget toward the two new hooks.
1345
+ findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1346
+ findings.push(..._handRolledWriteGateRules(source));
1279
1347
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
1280
1348
  // line-by-line scan so banned-identifier findings stay first in the
1281
1349
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.98.0",
3
+ "version": "0.99.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"