@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/README.md +69 -2
- package/dist/contract.cjs +83 -4
- package/dist/contract.js +83 -4
- package/dist/hooks.js +4659 -4344
- package/dist/index.d.ts +95 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/linter.cjs +68 -0
- package/dist/linter.js +68 -0
- package/package.json +2 -2
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
package/dist/index.native.js
CHANGED
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.
|
|
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"
|