@compean/core 0.1.1 → 0.1.2
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/changed.d.ts +1 -0
- package/dist/changed.d.ts.map +1 -0
- package/dist/controller.d.ts +1 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/eslint/index.d.ts +1 -0
- package/dist/eslint/index.d.ts.map +1 -0
- package/dist/form.d.ts +1 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/observable.d.ts +1 -0
- package/dist/observable.d.ts.map +1 -0
- package/dist/prettier/index.d.ts +1 -0
- package/dist/prettier/index.d.ts.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/read-json.d.ts +1 -0
- package/dist/read-json.d.ts.map +1 -0
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/report-safely.d.ts +1 -0
- package/dist/report-safely.d.ts.map +1 -0
- package/dist/reporting.d.ts +1 -0
- package/dist/reporting.d.ts.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/toast.service.d.ts +1 -0
- package/dist/toast.service.d.ts.map +1 -0
- package/dist/user-error.d.ts +1 -0
- package/dist/user-error.d.ts.map +1 -0
- package/package.json +4 -1
- package/src/changed.ts +16 -0
- package/src/controller.ts +128 -0
- package/src/eslint/index.ts +180 -0
- package/src/form.ts +114 -0
- package/src/index.ts +9 -0
- package/src/observable.ts +19 -0
- package/src/prettier/index.ts +18 -0
- package/src/react/index.tsx +88 -0
- package/src/read-json.ts +32 -0
- package/src/registry.ts +47 -0
- package/src/report-safely.ts +13 -0
- package/src/reporting.ts +25 -0
- package/src/server/index.ts +66 -0
- package/src/testing/index.ts +33 -0
- package/src/toast.service.ts +37 -0
- package/src/user-error.ts +15 -0
package/dist/changed.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ export interface DependsOnMeta<R extends string = string> {
|
|
|
8
8
|
* refresh. A writer calls this after a change and never needs to know who reads.
|
|
9
9
|
*/
|
|
10
10
|
export declare function changed<R extends string>(cache: Pick<QueryClient, 'invalidateQueries'>, resource: R): Promise<void>;
|
|
11
|
+
//# sourceMappingURL=changed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changed.d.ts","sourceRoot":"","sources":["../src/changed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,wEAAwE;AACxE,MAAM,WAAW,aAAa,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACpD,SAAS,CAAC,EAAE,SAAS,CAAC,EAAE,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,mBAAmB,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAInH"}
|
package/dist/controller.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAM7C;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAI7D;;;;;GAKG;AACH,8BAAsB,UAAU,CAAC,MAAM,CAAE,SAAQ,UAAU;IACvD,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD,OAAO,CAAC,aAAa,CAA+B;IACpD,oEAAoE;IACpE,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,wGAAwG;IACxG,OAAO,CAAC,iBAAiB,CAAK;IAC9B,uGAAuG;IACvG,OAAO,CAAC,gBAAgB,CAAuB;IAE/C,yGAAyG;IACzG,aAAoB,KAAK,IAAI,MAAM,CAAC;IAEpC;;;OAGG;IACI,UAAU,IAAI,IAAI,CAAG;IAE5B;;;OAGG;IACI,KAAK,IAAI,IAAI,CAKnB;IAED,0EAA0E;IACnE,OAAO,IAAI,IAAI,CAIrB;IAED,SAAS,KAAK,MAAM,IAAI,gBAAgB,CAEvC;IAED,6FAA6F;IAC7F,SAAS,KAAK,SAAS,IAAI,MAAM,GAAG,IAAI,CAEvC;IAED,uFAAuF;IACvF,SAAS,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAEzC;IAED;;;;;OAKG;IACH,UAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB7D;IAED;;;OAGG;IACH,UAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAM5D;IAED,0FAA0F;IAC1F,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,MAAM;CAKjB"}
|
package/dist/eslint/index.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/eslint/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,MAAM,EAAQ,MAAM,QAAQ,CAAC;AAwJnD;;;GAGG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,CAuB9C"}
|
package/dist/form.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../src/form.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,KAAK,WAAW,CAAC,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;AAEvD,MAAM,WAAW,QAAQ,CAAC,CAAC;IACvB,MAAM,EAAE,CAAC,CAAC;IACV,6EAA6E;IAC7E,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IACvB,4FAA4F;IAC5F,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oDAAoD;IACpD,SAAS,EAAE,OAAO,CAAC;CACtB;AAQD;;;GAGG;AACH,qBAAa,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,CAAE,SAAQ,UAAU;IAQnD,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR5B,OAAO,CAAC,MAAM,CAAa;IAC3B,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA+B;IACvD,iGAAiG;IACjG,OAAO,CAAC,aAAa,CAAS;IAE9B,YACqB,MAAM,EAAE,CAAC,EACT,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAIvC;IAED,uFAAuF;IAChF,GAAG,CAAC,CAAC,SAAS,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAG3E;IAED,+EAA+E;IACxE,KAAK,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAG1C;IAED,2DAA2D;IACpD,KAAK,IAAI,IAAI,CAKnB;IAED,qGAAqG;IAC9F,IAAI,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAelC;IAED;;;;;OAKG;IACU,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;IAED,OAAO,CAAC,QAAQ;CAgBnB"}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { Controller, type ControllerStatus } from './controller.js';
|
|
|
7
7
|
export { Form, type FormView } from './form.js';
|
|
8
8
|
export { readJson, type JsonResponse } from './read-json.js';
|
|
9
9
|
export { changed, type DependsOnMeta } from './changed.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,KAAK,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EAAE,IAAI,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/observable.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"observable.d.ts","sourceRoot":"","sources":["../src/observable.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,8BAAsB,UAAU;IAC5B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IAEnD,gFAAgF;IACzE,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAKjD;IAED,sHAAsH;IACtH,SAAS,CAAC,MAAM,IAAI,IAAI,CAIvB;CACJ"}
|
package/dist/prettier/index.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/prettier/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC;;;GAGG;AACH,QAAA,MAAM,MAAM,EAAE,MASb,CAAC;eAEa,MAAM"}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAkE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAgB,KAAK,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAE/D,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,SAAS,UAAU,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAoB5D;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,CAAC,OAAO,CAAC,EACvD,MAAM,EAAE,MAAM,CAAC,EACf,GAAG,CAAC,EAAE,MAAM,GACb;IAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAAC,UAAU,EAAE,CAAC,CAAA;CAAE,CAgCtC;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAe,EAAE,EAAE;IAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAc9H"}
|
package/dist/read-json.d.ts
CHANGED
|
@@ -11,3 +11,4 @@ export interface JsonResponse<T> {
|
|
|
11
11
|
* @throws Error for any other failure, which `load()` and `run()` treat as a bug.
|
|
12
12
|
*/
|
|
13
13
|
export declare function readJson<T>(request: Promise<JsonResponse<T>> | JsonResponse<T>): Promise<T>;
|
|
14
|
+
//# sourceMappingURL=read-json.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-json.d.ts","sourceRoot":"","sources":["../src/read-json.ts"],"names":[],"mappings":"AAEA,gEAAgE;AAChE,MAAM,WAAW,YAAY,CAAC,CAAC;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;CACtB;AAKD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,GAAG,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAajG"}
|
package/dist/registry.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAGA,KAAK,KAAK,CAAC,CAAC,IAAI,QAAQ,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AAarD,wGAAwG;AACxG,KAAK,WAAW,CAAC,CAAC,IAAI;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAE,CAAC;AAE/C;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAEpF;AAED;;;;GAIG;AACH,wBAAgB,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CASxC;AAED,wEAAwE;AACxE,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
|
package/dist/report-safely.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { type ReportContext } from './reporting.js';
|
|
2
2
|
/** Reports through the registered reporter, falling back to the console if that reporter fails. */
|
|
3
3
|
export declare function reportSafely(error: unknown, context: ReportContext): void;
|
|
4
|
+
//# sourceMappingURL=report-safely.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report-safely.d.ts","sourceRoot":"","sources":["../src/report-safely.ts"],"names":[],"mappings":"AACA,OAAO,EAAkC,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpF,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAQzE"}
|
package/dist/reporting.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reporting.d.ts","sourceRoot":"","sources":["../src/reporting.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC1B,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,8BAAsB,aAAa;IAC/B,mGAAmG;IACnG,SAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC;CACxE;AAED,qBAAa,eAAgB,YAAW,aAAa;IAC1C,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAM1D;CACJ"}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export declare function validator<Target extends keyof ValidationTargets, Schema
|
|
|
20
20
|
* @throws Error naming every missing or invalid variable, so a bad deploy stops at startup.
|
|
21
21
|
*/
|
|
22
22
|
export declare function loadConfig<Schema extends z.ZodObject>(schema: Schema, env?: Record<string, string | undefined>): z.output<Schema>;
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAG5D,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAM7B;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAiB3C;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,SAAS,MAAM,iBAAiB,EAAE,MAAM,SAAS,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;UAMzH;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC,SAAS,EACjD,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACtD,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAOlB"}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEpE,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,gEAAgE;AAChE,qBAAa,iBAAkB,YAAW,aAAa;IACnD,SAAgB,OAAO,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,aAAa,CAAA;KAAE,CAAC,CAAM;IAEzE,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAE1D;CACJ;AAED,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC,CAAA;CAAE,CAAC;AAEhF,2FAA2F;AAC3F,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,CAgBnE"}
|
package/dist/toast.service.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toast.service.d.ts","sourceRoot":"","sources":["../src/toast.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE5C,MAAM,WAAW,KAAK;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,CAAC;CACnB;AAED,gHAAgH;AAChH,qBAAa,YAAa,SAAQ,UAAU;IACxC,sDAAsD;IACtD,OAAO,CAAC,OAAO,CAAsB;IACrC,mFAAmF;IACnF,OAAO,CAAC,MAAM,CAAK;IAEnB,2HAA2H;IACpH,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,SAAqB,GAAG,IAAI,CAG9D;IAED,0DAA0D;IACnD,MAAM,IAAI,KAAK,GAAG,IAAI,CAE5B;IAED,yDAAyD;IAClD,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAM/B;CACJ"}
|
package/dist/user-error.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-error.d.ts","sourceRoot":"","sources":["../src/user-error.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAE1D;;;GAGG;AACH,qBAAa,SAAU,SAAQ,KAAK;aAGZ,MAAM,EAAE,eAAe;IAF3C,YACI,OAAO,EAAE,MAAM,EACC,MAAM,GAAE,eAAqB,EAIhD;CACJ"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compean/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Shared core for Compean apps: controllers, services, errors, forms and the framework rules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/**/*.test.tsx",
|
|
17
20
|
"RULES.md"
|
|
18
21
|
],
|
|
19
22
|
"exports": {
|
package/src/changed.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/query-core';
|
|
2
|
+
|
|
3
|
+
/** Query metadata naming the resources a cached query is built from. */
|
|
4
|
+
export interface DependsOnMeta<R extends string = string> {
|
|
5
|
+
dependsOn?: readonly R[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Marks stale every cached query whose `meta.dependsOn` lists `resource`, so screens showing it
|
|
10
|
+
* refresh. A writer calls this after a change and never needs to know who reads.
|
|
11
|
+
*/
|
|
12
|
+
export function changed<R extends string>(cache: Pick<QueryClient, 'invalidateQueries'>, resource: R): Promise<void> {
|
|
13
|
+
return cache.invalidateQueries({
|
|
14
|
+
predicate: (query) => (query.meta as DependsOnMeta | undefined)?.dependsOn?.includes(resource) ?? false,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Observable } from './observable.js';
|
|
2
|
+
import { $S } from './registry.js';
|
|
3
|
+
import { reportSafely } from './report-safely.js';
|
|
4
|
+
import { ToastService } from './toast.service.js';
|
|
5
|
+
import { UserError } from './user-error.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the screen shows as a whole: a loading view, the content, or a full-screen error with retry.
|
|
9
|
+
* See "Controller status" in the README's design notes.
|
|
10
|
+
*/
|
|
11
|
+
export type ControllerStatus = 'loading' | 'ready' | 'error';
|
|
12
|
+
|
|
13
|
+
const GENERIC_MESSAGE = 'Something went wrong. Try again.';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One screen's logic and state. `state` is what the screen renders; actions are public methods.
|
|
17
|
+
* A controller runs unchanged on web and mobile, so it never imports React, React Native or a
|
|
18
|
+
* router. Subclasses override `initialize()` and `state`, load data with `load()` and perform
|
|
19
|
+
* actions with `run()`; neither needs a try/catch.
|
|
20
|
+
*/
|
|
21
|
+
export abstract class Controller<TState> extends Observable {
|
|
22
|
+
/** Undone on `dispose()` in reverse order, so the last thing tracked is undone first. */
|
|
23
|
+
private readonly cleanups: Array<() => void> = [];
|
|
24
|
+
private currentStatus: ControllerStatus = 'loading';
|
|
25
|
+
/** The message for the 'error' status; null in any other status. */
|
|
26
|
+
private currentLoadError: string | null = null;
|
|
27
|
+
/** Loads started before the screen was first ready and not yet finished. The status settles at zero. */
|
|
28
|
+
private pendingFirstLoads = 0;
|
|
29
|
+
/** The first failure among the pending first loads; it becomes the load error once they all finish. */
|
|
30
|
+
private firstLoadFailure: string | null = null;
|
|
31
|
+
|
|
32
|
+
/** Read by the screen after every `notify()`. Build it from fields; never fetch or do slow work here. */
|
|
33
|
+
public abstract get state(): TState;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subclass hook, called once by `start()` when the screen mounts. Start loading with `load()`
|
|
37
|
+
* and subscribe to services here, registering each unsubscribe with `track()`.
|
|
38
|
+
*/
|
|
39
|
+
public initialize(): void {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mounts the controller: calls `initialize()`, then marks a screen that started no load as
|
|
43
|
+
* ready. `useController` calls this; tests call it in place of mounting a screen.
|
|
44
|
+
*/
|
|
45
|
+
public start(): void {
|
|
46
|
+
this.initialize();
|
|
47
|
+
if (this.pendingFirstLoads === 0 && this.currentStatus === 'loading') {
|
|
48
|
+
this.settle('ready', null);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Safe to call more than once: each cleanup runs only the first time. */
|
|
53
|
+
public dispose(): void {
|
|
54
|
+
for (const cleanup of this.cleanups.splice(0).reverse()) {
|
|
55
|
+
cleanup();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected get status(): ControllerStatus {
|
|
60
|
+
return this.currentStatus;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The message to show when status is 'error': a UserError's own words, or a generic one. */
|
|
64
|
+
protected get loadError(): string | null {
|
|
65
|
+
return this.currentLoadError;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Runs `cleanup` (an unsubscribe, a clearTimeout) when the controller is disposed. */
|
|
69
|
+
protected track(cleanup: () => void): void {
|
|
70
|
+
this.cleanups.push(cleanup);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Loads the screen's data. Before the screen is first ready, the status settles once every
|
|
75
|
+
* load started so far has finished: 'ready' if all succeeded, otherwise 'error' with the first
|
|
76
|
+
* failure's message (shown full screen; retry by calling `load()` again). Once ready, a load is
|
|
77
|
+
* a refresh: the data stays on screen and a failure shows as a toast. Never rejects.
|
|
78
|
+
*/
|
|
79
|
+
protected async load(task: () => Promise<void>): Promise<void> {
|
|
80
|
+
if (this.currentStatus === 'ready') {
|
|
81
|
+
await this.run(task);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (this.currentStatus === 'error') {
|
|
85
|
+
this.settle('loading', null);
|
|
86
|
+
}
|
|
87
|
+
this.pendingFirstLoads += 1;
|
|
88
|
+
try {
|
|
89
|
+
await task();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.firstLoadFailure ??= this.describeAndReport(error);
|
|
92
|
+
} finally {
|
|
93
|
+
this.pendingFirstLoads -= 1;
|
|
94
|
+
if (this.pendingFirstLoads === 0) {
|
|
95
|
+
const failure = this.firstLoadFailure;
|
|
96
|
+
this.firstLoadFailure = null;
|
|
97
|
+
this.settle(failure ? 'error' : 'ready', failure);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Performs an action. It never changes the screen's status: a failure shows as an error toast,
|
|
104
|
+
* with a UserError's own message or a generic one, and bugs are reported. Never rejects.
|
|
105
|
+
*/
|
|
106
|
+
protected async run(task: () => Promise<void>): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await task();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
$S(ToastService).show(this.describeAndReport(error), 'error');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The message the user sees for this error; reports it first unless it's a UserError. */
|
|
115
|
+
private describeAndReport(error: unknown): string {
|
|
116
|
+
if (error instanceof UserError) {
|
|
117
|
+
return error.message;
|
|
118
|
+
}
|
|
119
|
+
reportSafely(error, { where: this.constructor.name });
|
|
120
|
+
return GENERIC_MESSAGE;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private settle(status: ControllerStatus, loadError: string | null): void {
|
|
124
|
+
this.currentStatus = status;
|
|
125
|
+
this.currentLoadError = loadError;
|
|
126
|
+
this.notify();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ESLint, Linter, Rule } from 'eslint';
|
|
2
|
+
|
|
3
|
+
type ImportNode = { source?: { value?: unknown } | null };
|
|
4
|
+
|
|
5
|
+
/** Packages that only run on one platform: React, React Native, Expo and the routers. */
|
|
6
|
+
const PLATFORM_IMPORT =
|
|
7
|
+
/^(react|react-dom|react-native|react-router|expo)(\/|$)|^react-native-|^react-router-|^@tanstack\/react-|^expo-|^@expo\/|^@react-navigation\//;
|
|
8
|
+
/** What packages/domain may import: its own files and zod. */
|
|
9
|
+
const DOMAIN_ALLOWED = /^(\.{1,2}\/|zod(\/|$))/;
|
|
10
|
+
/** Tests may import their runner and test libraries, which the platform and purity rules would flag. */
|
|
11
|
+
const TEST_FILES = ['**/*.test.{ts,tsx}'];
|
|
12
|
+
|
|
13
|
+
/** TanStack Router calls whose options object defines a route. */
|
|
14
|
+
const ROUTE_FACTORIES = new Set([
|
|
15
|
+
'createFileRoute',
|
|
16
|
+
'createRoute',
|
|
17
|
+
'createRootRoute',
|
|
18
|
+
'createRootRouteWithContext',
|
|
19
|
+
'createLazyFileRoute',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Which files may import a library that must stay hidden behind one kind of module. */
|
|
23
|
+
const LIBRARY_OWNERS: ReadonlyArray<{ library: RegExp; owner: RegExp; ownerName: string }> = [
|
|
24
|
+
{
|
|
25
|
+
library: /^@tanstack\/(query-core|react-query)(\/|$)/,
|
|
26
|
+
owner: /(\.service|(^|[\\/])boot)\.tsx?$/,
|
|
27
|
+
ownerName: 'a client service (*.service.ts) or boot.ts',
|
|
28
|
+
},
|
|
29
|
+
{ library: /^better-auth(\/|$)/, owner: /(^|[\\/])auth\.service\.tsx?$/, ownerName: 'the auth service (auth.service.ts)' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function importSource(node: ImportNode): string | null {
|
|
33
|
+
const value = node.source?.value;
|
|
34
|
+
return typeof value === 'string' ? value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function propertyName(key: Rule.Node | { type: string; name?: string; value?: unknown }): unknown {
|
|
38
|
+
if (key.type === 'Identifier') {
|
|
39
|
+
return (key as { name: string }).name;
|
|
40
|
+
}
|
|
41
|
+
if (key.type === 'Literal') {
|
|
42
|
+
return (key as { value: unknown }).value;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function calleeName(callee: { type: string; name?: string; callee?: unknown }): string | null {
|
|
48
|
+
if (callee.type === 'Identifier') {
|
|
49
|
+
return callee.name ?? null;
|
|
50
|
+
}
|
|
51
|
+
if (callee.type === 'CallExpression') {
|
|
52
|
+
return calleeName(callee.callee as { type: string; name?: string; callee?: unknown });
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True for the options object of `createFileRoute('/path')({...})`, `createRoute({...})` and similar. */
|
|
58
|
+
function isRouteDefinition(object: Rule.Node | undefined): boolean {
|
|
59
|
+
const call = object?.parent;
|
|
60
|
+
if (object?.type !== 'ObjectExpression' || call?.type !== 'CallExpression') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const name = calleeName(call.callee as { type: string; name?: string; callee?: unknown });
|
|
64
|
+
return name !== null && ROUTE_FACTORIES.has(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onImports(check: (node: Rule.Node, source: string) => void): Rule.RuleListener {
|
|
68
|
+
const visit = (node: Rule.Node) => {
|
|
69
|
+
const source = importSource(node as unknown as ImportNode);
|
|
70
|
+
if (source) {
|
|
71
|
+
check(node, source);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return { ImportDeclaration: visit, ExportNamedDeclaration: visit, ExportAllDeclaration: visit, ImportExpression: visit };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const platformFree: Rule.RuleModule = {
|
|
78
|
+
meta: {
|
|
79
|
+
type: 'problem',
|
|
80
|
+
docs: { description: 'Shared client code and controllers run on web and mobile, so they import no platform or router.' },
|
|
81
|
+
messages: {
|
|
82
|
+
platform:
|
|
83
|
+
"'{{source}}' is platform code. Controllers and packages/client stay platform-free; use it in a screen or an adapter.",
|
|
84
|
+
},
|
|
85
|
+
schema: [],
|
|
86
|
+
},
|
|
87
|
+
create: (context) =>
|
|
88
|
+
onImports((node, source) => {
|
|
89
|
+
if (PLATFORM_IMPORT.test(source)) {
|
|
90
|
+
context.report({ node, messageId: 'platform', data: { source } });
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const domainPure: Rule.RuleModule = {
|
|
96
|
+
meta: {
|
|
97
|
+
type: 'problem',
|
|
98
|
+
docs: { description: 'packages/domain is shared by client and server, so it imports only itself and Zod.' },
|
|
99
|
+
messages: { impure: "packages/domain may import only its own files and zod, not '{{source}}'." },
|
|
100
|
+
schema: [],
|
|
101
|
+
},
|
|
102
|
+
create: (context) =>
|
|
103
|
+
onImports((node, source) => {
|
|
104
|
+
if (!DOMAIN_ALLOWED.test(source)) {
|
|
105
|
+
context.report({ node, messageId: 'impure', data: { source } });
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const noRouteLoaders: Rule.RuleModule = {
|
|
111
|
+
meta: {
|
|
112
|
+
type: 'problem',
|
|
113
|
+
docs: { description: 'Controllers load data; route files only pick a screen.' },
|
|
114
|
+
messages: { loader: "Routes never use loaders. Load data in the screen's controller through a service." },
|
|
115
|
+
schema: [],
|
|
116
|
+
},
|
|
117
|
+
create: (context) => ({
|
|
118
|
+
Property(node) {
|
|
119
|
+
if (!node.computed && propertyName(node.key) === 'loader' && isRouteDefinition(node.parent)) {
|
|
120
|
+
context.report({ node, messageId: 'loader' });
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const libraryOwners: Rule.RuleModule = {
|
|
127
|
+
meta: {
|
|
128
|
+
type: 'problem',
|
|
129
|
+
docs: { description: 'Libraries hidden behind one kind of module stay there.' },
|
|
130
|
+
messages: { owner: "Only {{ownerName}} may import '{{source}}'." },
|
|
131
|
+
schema: [],
|
|
132
|
+
},
|
|
133
|
+
create: (context) =>
|
|
134
|
+
onImports((node, source) => {
|
|
135
|
+
for (const { library, owner, ownerName } of LIBRARY_OWNERS) {
|
|
136
|
+
if (library.test(source) && !owner.test(context.filename)) {
|
|
137
|
+
context.report({ node, messageId: 'owner', data: { source, ownerName } });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const plugin: ESLint.Plugin = {
|
|
144
|
+
meta: { name: '@compean/core/eslint' },
|
|
145
|
+
rules: {
|
|
146
|
+
'platform-free': platformFree,
|
|
147
|
+
'domain-pure': domainPure,
|
|
148
|
+
'no-route-loaders': noRouteLoaders,
|
|
149
|
+
'library-owners': libraryOwners,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The framework rules a tool can check, as an ESLint flat config. Add it after your TypeScript
|
|
155
|
+
* parser setup: `export default [...typescriptConfig, ...compeanRules()]`.
|
|
156
|
+
*/
|
|
157
|
+
export function compeanRules(): Linter.Config[] {
|
|
158
|
+
return [
|
|
159
|
+
{ plugins: { compean: plugin } },
|
|
160
|
+
{
|
|
161
|
+
files: ['packages/client/**/*.{ts,tsx}', '**/*.controller.{ts,tsx}'],
|
|
162
|
+
ignores: TEST_FILES,
|
|
163
|
+
rules: { 'compean/platform-free': 'error' },
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
files: ['packages/domain/**/*.{ts,tsx}'],
|
|
167
|
+
ignores: TEST_FILES,
|
|
168
|
+
rules: { 'compean/domain-pure': 'error' },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
files: ['apps/web/routes/**/*.{ts,tsx}'],
|
|
172
|
+
rules: { 'compean/no-route-loaders': 'error' },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
files: ['packages/client/**/*.{ts,tsx}', 'apps/web/**/*.{ts,tsx}', 'apps/mobile/**/*.{ts,tsx}'],
|
|
176
|
+
ignores: TEST_FILES,
|
|
177
|
+
rules: { 'compean/library-owners': 'error' },
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import { Observable } from './observable.js';
|
|
3
|
+
|
|
4
|
+
type FieldErrors<V> = Partial<Record<keyof V, string>>;
|
|
5
|
+
|
|
6
|
+
export interface FormView<V> {
|
|
7
|
+
values: V;
|
|
8
|
+
/** Errors for fields the user has touched, or all of them after a submit. */
|
|
9
|
+
errors: FieldErrors<V>;
|
|
10
|
+
/** An error that belongs to the whole form (a rule across fields), shown after a submit. */
|
|
11
|
+
formError: string | null;
|
|
12
|
+
/** True when the current values pass the schema. */
|
|
13
|
+
canSubmit: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Validation<V> {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
fieldErrors: FieldErrors<V>;
|
|
19
|
+
formError: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A form's values and errors, checked by a domain schema. A controller holds one as
|
|
24
|
+
* `public readonly`, the screen binds inputs to it, and `submit()` runs only valid input.
|
|
25
|
+
*/
|
|
26
|
+
export class Form<S extends z.ZodObject> extends Observable {
|
|
27
|
+
private values: z.input<S>;
|
|
28
|
+
/** Fields allowed to show their error: visited by the user, or every field after a failed submit. */
|
|
29
|
+
private readonly touched = new Set<keyof z.input<S>>();
|
|
30
|
+
/** Set by a failed submit: form-level errors belong to no field, so no touch can reveal them. */
|
|
31
|
+
private showFormError = false;
|
|
32
|
+
|
|
33
|
+
public constructor(
|
|
34
|
+
private readonly schema: S,
|
|
35
|
+
private readonly initial: z.input<S>,
|
|
36
|
+
) {
|
|
37
|
+
super();
|
|
38
|
+
this.values = { ...initial };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** If the field was touched, its visible error is re-checked against the new value. */
|
|
42
|
+
public set<K extends keyof z.input<S>>(field: K, value: z.input<S>[K]): void {
|
|
43
|
+
this.values = { ...this.values, [field]: value };
|
|
44
|
+
this.notify();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Marks a field as visited (usually on blur), so its error starts showing. */
|
|
48
|
+
public touch(field: keyof z.input<S>): void {
|
|
49
|
+
this.touched.add(field);
|
|
50
|
+
this.notify();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Back to the initial values, with every error hidden. */
|
|
54
|
+
public reset(): void {
|
|
55
|
+
this.values = { ...this.initial };
|
|
56
|
+
this.touched.clear();
|
|
57
|
+
this.showFormError = false;
|
|
58
|
+
this.notify();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A snapshot for the screen: a copy of the values, the visible errors and whether it can submit. */
|
|
62
|
+
public view(): FormView<z.input<S>> {
|
|
63
|
+
const validation = this.validate();
|
|
64
|
+
const errors: FieldErrors<z.input<S>> = {};
|
|
65
|
+
for (const field of this.touched) {
|
|
66
|
+
const message = validation.fieldErrors[field];
|
|
67
|
+
if (message) {
|
|
68
|
+
errors[field] = message;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
values: { ...this.values },
|
|
73
|
+
errors,
|
|
74
|
+
formError: this.showFormError ? validation.formError : null,
|
|
75
|
+
canSubmit: validation.valid,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs `action` with the parsed input when it's valid; otherwise shows every error and skips
|
|
81
|
+
* it. The values stay as they are either way; clear them with `reset()` inside `action`.
|
|
82
|
+
*
|
|
83
|
+
* @throws whatever `action` throws, so the surrounding `run()` reports it.
|
|
84
|
+
*/
|
|
85
|
+
public async submit(action: (input: z.output<S>) => Promise<void>): Promise<void> {
|
|
86
|
+
const result = this.schema.safeParse(this.values);
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
this.showFormError = true;
|
|
89
|
+
for (const field of Object.keys(this.schema.shape) as Array<keyof z.input<S>>) {
|
|
90
|
+
this.touched.add(field);
|
|
91
|
+
}
|
|
92
|
+
this.notify();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await action(result.data);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private validate(): Validation<z.input<S>> {
|
|
99
|
+
const result = this.schema.safeParse(this.values);
|
|
100
|
+
const validation: Validation<z.input<S>> = { valid: result.success, fieldErrors: {}, formError: null };
|
|
101
|
+
if (result.success) {
|
|
102
|
+
return validation;
|
|
103
|
+
}
|
|
104
|
+
for (const issue of result.error.issues) {
|
|
105
|
+
const field = issue.path[0] as keyof z.input<S> | undefined;
|
|
106
|
+
if (field === undefined) {
|
|
107
|
+
validation.formError ??= issue.message;
|
|
108
|
+
} else if (!validation.fieldErrors[field]) {
|
|
109
|
+
validation.fieldErrors[field] = issue.message;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return validation;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Observable } from './observable.js';
|
|
2
|
+
export { $S, register } from './registry.js';
|
|
3
|
+
export { UserError, type UserErrorStatus } from './user-error.js';
|
|
4
|
+
export { ErrorReporter, ConsoleReporter, type ReportContext } from './reporting.js';
|
|
5
|
+
export { ToastService, type Toast, type ToastTone } from './toast.service.js';
|
|
6
|
+
export { Controller, type ControllerStatus } from './controller.js';
|
|
7
|
+
export { Form, type FormView } from './form.js';
|
|
8
|
+
export { readJson, type JsonResponse } from './read-json.js';
|
|
9
|
+
export { changed, type DependsOnMeta } from './changed.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Something screens can watch: subscribers are called after every change. */
|
|
2
|
+
export abstract class Observable {
|
|
3
|
+
private readonly listeners = new Set<() => void>();
|
|
4
|
+
|
|
5
|
+
/** Calls `listener` after each change until the returned function is called. */
|
|
6
|
+
public subscribe(listener: () => void): () => void {
|
|
7
|
+
this.listeners.add(listener);
|
|
8
|
+
return () => {
|
|
9
|
+
this.listeners.delete(listener);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Calls every listener synchronously, in subscription order. Listeners added during a call wait for the next one. */
|
|
14
|
+
protected notify(): void {
|
|
15
|
+
for (const listener of [...this.listeners]) {
|
|
16
|
+
listener();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Config } from 'prettier';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The formatting every Compean project shares. Point to it from package.json with
|
|
5
|
+
* `"prettier": "@compean/core/prettier"` instead of copying it.
|
|
6
|
+
*/
|
|
7
|
+
const config: Config = {
|
|
8
|
+
printWidth: 130,
|
|
9
|
+
tabWidth: 4,
|
|
10
|
+
useTabs: false,
|
|
11
|
+
singleQuote: true,
|
|
12
|
+
jsxSingleQuote: true,
|
|
13
|
+
semi: true,
|
|
14
|
+
trailingComma: 'all',
|
|
15
|
+
bracketSpacing: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default config;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
|
+
import type { Controller } from '../controller.js';
|
|
3
|
+
import { $S } from '../registry.js';
|
|
4
|
+
import { ToastService, type Toast } from '../toast.service.js';
|
|
5
|
+
|
|
6
|
+
type StateOf<C> = C extends Controller<infer S> ? S : never;
|
|
7
|
+
|
|
8
|
+
/** The controller this hook rendered, the key it was created for, and its latest state. */
|
|
9
|
+
interface Mounted<C> {
|
|
10
|
+
controller: C;
|
|
11
|
+
key: string | undefined;
|
|
12
|
+
state: StateOf<C>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Controllers this hook has disposed. React StrictMode re-runs effects on the same instance after
|
|
17
|
+
* disposing it; the hook mounts a fresh controller instead of starting a disposed one.
|
|
18
|
+
*/
|
|
19
|
+
const disposed = new WeakSet<Controller<unknown>>();
|
|
20
|
+
|
|
21
|
+
function mount<C extends Controller<unknown>>(create: () => C, key: string | undefined): Mounted<C> {
|
|
22
|
+
const controller = create();
|
|
23
|
+
return { controller, key, state: controller.state as StateOf<C> };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a screen's controller, initializes it, re-renders on every `notify()` and disposes it on
|
|
28
|
+
* unmount. A new `key` (such as a route param) replaces the controller. A controller is never
|
|
29
|
+
* initialized again after it was disposed, so React StrictMode gets a fresh one instead.
|
|
30
|
+
* `create` runs during render and may run more than once, so a constructor must not subscribe
|
|
31
|
+
* or start work; do that in `initialize()`.
|
|
32
|
+
*/
|
|
33
|
+
export function useController<C extends Controller<unknown>>(
|
|
34
|
+
create: () => C,
|
|
35
|
+
key?: string,
|
|
36
|
+
): { state: StateOf<C>; controller: C } {
|
|
37
|
+
const createRef = useRef(create);
|
|
38
|
+
createRef.current = create;
|
|
39
|
+
const [mounted, setMounted] = useState(() => mount(create, key));
|
|
40
|
+
|
|
41
|
+
let current = mounted;
|
|
42
|
+
if (mounted.key !== key) {
|
|
43
|
+
current = mount(create, key);
|
|
44
|
+
setMounted(current);
|
|
45
|
+
}
|
|
46
|
+
const { controller } = current;
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (disposed.has(controller)) {
|
|
50
|
+
setMounted((previous) => (previous.controller === controller ? mount(createRef.current, previous.key) : previous));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const refresh = () =>
|
|
54
|
+
setMounted((previous) =>
|
|
55
|
+
previous.controller === controller ? { ...previous, state: controller.state as StateOf<C> } : previous,
|
|
56
|
+
);
|
|
57
|
+
const unsubscribe = controller.subscribe(refresh);
|
|
58
|
+
refresh();
|
|
59
|
+
controller.start();
|
|
60
|
+
return () => {
|
|
61
|
+
unsubscribe();
|
|
62
|
+
disposed.add(controller);
|
|
63
|
+
controller.dispose();
|
|
64
|
+
};
|
|
65
|
+
}, [controller]);
|
|
66
|
+
|
|
67
|
+
return { state: current.state, controller };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shows the latest toast from ToastService, rendered by the app's own `children`, and hides it
|
|
72
|
+
* after `duration` milliseconds. Mount it once at the app root.
|
|
73
|
+
*/
|
|
74
|
+
export function Toaster({ children, duration = 4000 }: { children: (toast: Toast) => ReactNode; duration?: number }): ReactNode {
|
|
75
|
+
const toasts = $S(ToastService);
|
|
76
|
+
const subscribe = useCallback((onChange: () => void) => toasts.subscribe(onChange), [toasts]);
|
|
77
|
+
const toast = useSyncExternalStore(subscribe, () => toasts.latest());
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!toast) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const timer = setTimeout(() => toasts.dismiss(toast.id), duration);
|
|
84
|
+
return () => clearTimeout(timer);
|
|
85
|
+
}, [toast, toasts, duration]);
|
|
86
|
+
|
|
87
|
+
return toast ? children(toast) : null;
|
|
88
|
+
}
|
package/src/read-json.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { UserError, type UserErrorStatus } from './user-error.js';
|
|
2
|
+
|
|
3
|
+
/** The part of a fetch or hc response that `readJson` needs. */
|
|
4
|
+
export interface JsonResponse<T> {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
status: number;
|
|
7
|
+
json(): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Statuses a UserError can carry; any other 4xx is shown as a 400. */
|
|
11
|
+
const KNOWN_STATUSES: ReadonlySet<number> = new Set<UserErrorStatus>([400, 401, 403, 404, 409]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The body of a successful response; undefined for 204 No Content.
|
|
15
|
+
*
|
|
16
|
+
* @throws UserError for a 4xx carrying `{ message }`, so `load()` and `run()` show its words.
|
|
17
|
+
* @throws Error for any other failure, which `load()` and `run()` treat as a bug.
|
|
18
|
+
*/
|
|
19
|
+
export async function readJson<T>(request: Promise<JsonResponse<T>> | JsonResponse<T>): Promise<T> {
|
|
20
|
+
const response = await request;
|
|
21
|
+
if (response.ok) {
|
|
22
|
+
return response.status === 204 ? (undefined as T) : response.json();
|
|
23
|
+
}
|
|
24
|
+
if (response.status >= 400 && response.status < 500) {
|
|
25
|
+
const body = (await response.json().catch(() => null)) as { message?: unknown } | null;
|
|
26
|
+
if (typeof body?.message === 'string') {
|
|
27
|
+
const status = KNOWN_STATUSES.has(response.status) ? (response.status as UserErrorStatus) : 400;
|
|
28
|
+
throw new UserError(body.message, status);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Request failed with status ${response.status}`);
|
|
32
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ConsoleReporter, ErrorReporter } from './reporting.js';
|
|
2
|
+
import { ToastService } from './toast.service.js';
|
|
3
|
+
|
|
4
|
+
type Token<T> = abstract new (...args: never[]) => T;
|
|
5
|
+
|
|
6
|
+
const services = new Map<Token<unknown>, unknown>();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Framework services that work without wiring, so tests and local runs need no boot code. A
|
|
10
|
+
* default must be what production wants; apps replace the reporter with a vendor one.
|
|
11
|
+
*/
|
|
12
|
+
const defaults = new Map<Token<unknown>, () => unknown>([
|
|
13
|
+
[ToastService, () => new ToastService()],
|
|
14
|
+
[ErrorReporter, () => new ConsoleReporter()],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** What callers of `$S(token)` can use: the class's public members, so a test fake needs only those. */
|
|
18
|
+
type PublicShape<T> = { [K in keyof T]: T[K] };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Makes `instance` the one `$S(token)` returns. Called from boot.ts and from tests. The type comes
|
|
22
|
+
* from the class alone; `instance` must offer its public members, so a fake with extra fields fits.
|
|
23
|
+
*/
|
|
24
|
+
export function register<T>(token: Token<T>, instance: NoInfer<PublicShape<T>>): void {
|
|
25
|
+
services.set(token, instance);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The service registered for this class, or a framework default (ToastService, ErrorReporter).
|
|
30
|
+
*
|
|
31
|
+
* @throws Error when nothing was registered for it: a wiring bug, fixed in boot.ts.
|
|
32
|
+
*/
|
|
33
|
+
export function $S<T>(token: Token<T>): T {
|
|
34
|
+
if (!services.has(token)) {
|
|
35
|
+
const create = defaults.get(token);
|
|
36
|
+
if (!create) {
|
|
37
|
+
throw new Error(`${token.name} is not registered. Register it in boot.ts.`);
|
|
38
|
+
}
|
|
39
|
+
services.set(token, create());
|
|
40
|
+
}
|
|
41
|
+
return services.get(token) as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Forgets every registered service. Tests call it before each test. */
|
|
45
|
+
export function resetServices(): void {
|
|
46
|
+
services.clear();
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { $S } from './registry.js';
|
|
2
|
+
import { ConsoleReporter, ErrorReporter, type ReportContext } from './reporting.js';
|
|
3
|
+
|
|
4
|
+
/** Reports through the registered reporter, falling back to the console if that reporter fails. */
|
|
5
|
+
export function reportSafely(error: unknown, context: ReportContext): void {
|
|
6
|
+
try {
|
|
7
|
+
$S(ErrorReporter).report(error, context);
|
|
8
|
+
} catch (reporterFailure) {
|
|
9
|
+
const console = new ConsoleReporter();
|
|
10
|
+
console.report(error, context);
|
|
11
|
+
console.report(reporterFailure, { where: 'ErrorReporter' });
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/reporting.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ReportContext {
|
|
2
|
+
/** Where it happened: a controller name, or "METHOD /path" on the server. */
|
|
3
|
+
where: string;
|
|
4
|
+
userId?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where bugs go. Register one per app in boot.ts: the console locally and in tests, a vendor
|
|
9
|
+
* adapter such as Sentry in production. Only `load()`, `run()` and the server's error handler call it,
|
|
10
|
+
* and they survive a reporter that throws.
|
|
11
|
+
*/
|
|
12
|
+
export abstract class ErrorReporter {
|
|
13
|
+
/** Records one bug with where it happened. Implementations may throw; callers guard against it. */
|
|
14
|
+
public abstract report(error: unknown, context: ReportContext): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ConsoleReporter implements ErrorReporter {
|
|
18
|
+
public report(error: unknown, context: ReportContext): void {
|
|
19
|
+
try {
|
|
20
|
+
console.error(`[${context.where}]`, error);
|
|
21
|
+
} catch {
|
|
22
|
+
// Reporting must never break the code that reports.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { zValidator } from '@hono/zod-validator';
|
|
2
|
+
import type { ErrorHandler, ValidationTargets } from 'hono';
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
import { reportSafely } from '../report-safely.js';
|
|
7
|
+
import { UserError } from '../user-error.js';
|
|
8
|
+
|
|
9
|
+
const GENERIC_MESSAGE = 'Something went wrong';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The app's one error handler: `app.onError(handleErrors())`. A UserError becomes `{ message }`
|
|
13
|
+
* with its status; an HTTPException below 500 (from Hono middleware) keeps its own response;
|
|
14
|
+
* anything else is reported and answered with a generic 500.
|
|
15
|
+
*/
|
|
16
|
+
export function handleErrors(): ErrorHandler {
|
|
17
|
+
return (error, c) => {
|
|
18
|
+
if (error instanceof UserError) {
|
|
19
|
+
return c.json({ message: error.message }, error.status);
|
|
20
|
+
}
|
|
21
|
+
if (error instanceof HTTPException && error.status < 500) {
|
|
22
|
+
if (error.res) {
|
|
23
|
+
return error.getResponse();
|
|
24
|
+
}
|
|
25
|
+
return c.json(
|
|
26
|
+
{ message: error.message || 'The request could not be processed' },
|
|
27
|
+
error.status as ContentfulStatusCode,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
reportSafely(error, { where: `${c.req.method} ${c.req.path}` });
|
|
31
|
+
return c.json({ message: GENERIC_MESSAGE }, 500);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* zValidator that reports invalid input as a UserError, so it reaches the client through the same
|
|
37
|
+
* handler and shape as every other user-facing error.
|
|
38
|
+
*/
|
|
39
|
+
export function validator<Target extends keyof ValidationTargets, Schema extends z.ZodType>(target: Target, schema: Schema) {
|
|
40
|
+
return zValidator(target, schema, (result) => {
|
|
41
|
+
if (!result.success) {
|
|
42
|
+
throw new UserError(describeIssues(result.error.issues), 400);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The environment, parsed and defaulted by `schema`. Call it first in boot.ts.
|
|
49
|
+
*
|
|
50
|
+
* @throws Error naming every missing or invalid variable, so a bad deploy stops at startup.
|
|
51
|
+
*/
|
|
52
|
+
export function loadConfig<Schema extends z.ZodObject>(
|
|
53
|
+
schema: Schema,
|
|
54
|
+
env: Record<string, string | undefined> = process.env,
|
|
55
|
+
): z.output<Schema> {
|
|
56
|
+
const result = schema.safeParse(env);
|
|
57
|
+
if (!result.success) {
|
|
58
|
+
const lines = result.error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`);
|
|
59
|
+
throw new Error(`Invalid configuration:\n${lines.join('\n')}`);
|
|
60
|
+
}
|
|
61
|
+
return result.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function describeIssues(issues: readonly z.core.$ZodIssue[]): string {
|
|
65
|
+
return issues.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`).join('; ');
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ErrorReporter, type ReportContext } from '../reporting.js';
|
|
2
|
+
|
|
3
|
+
export { resetServices } from '../registry.js';
|
|
4
|
+
|
|
5
|
+
/** An ErrorReporter that keeps every report, for assertions. */
|
|
6
|
+
export class RecordingReporter implements ErrorReporter {
|
|
7
|
+
public readonly reports: Array<{ error: unknown; context: ReportContext }> = [];
|
|
8
|
+
|
|
9
|
+
public report(error: unknown, context: ReportContext): void {
|
|
10
|
+
this.reports.push({ error, context });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Recorded<T> = T & { readonly calls: Array<[string, ...unknown[]]> };
|
|
15
|
+
|
|
16
|
+
/** A navigation object for controller tests: every method exists and records its calls. */
|
|
17
|
+
export function recordingNavigation<T extends object>(): Recorded<T> {
|
|
18
|
+
const calls: Array<[string, ...unknown[]]> = [];
|
|
19
|
+
return new Proxy({ calls } as Recorded<T>, {
|
|
20
|
+
get(target, property) {
|
|
21
|
+
if (property === 'calls') {
|
|
22
|
+
return calls;
|
|
23
|
+
}
|
|
24
|
+
// Not a thenable, so awaiting it or returning it from async code doesn't call `then()`.
|
|
25
|
+
if (property === 'then' || typeof property !== 'string') {
|
|
26
|
+
return Reflect.get(target, property);
|
|
27
|
+
}
|
|
28
|
+
return (...args: unknown[]) => {
|
|
29
|
+
calls.push([property, ...args]);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Observable } from './observable.js';
|
|
2
|
+
|
|
3
|
+
export type ToastTone = 'success' | 'error';
|
|
4
|
+
|
|
5
|
+
export interface Toast {
|
|
6
|
+
id: number;
|
|
7
|
+
message: string;
|
|
8
|
+
tone: ToastTone;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The one channel for short feedback messages. Controllers write the words; `<Toaster />` decides the look. */
|
|
12
|
+
export class ToastService extends Observable {
|
|
13
|
+
/** The toast on screen; null when none is showing. */
|
|
14
|
+
private current: Toast | null = null;
|
|
15
|
+
/** Ids increase, so a late `dismiss` of an older toast never hides a newer one. */
|
|
16
|
+
private nextId = 1;
|
|
17
|
+
|
|
18
|
+
/** Shows `message`, replacing any toast on screen. Use 'error' only outside `load()` and `run()`, which show their own. */
|
|
19
|
+
public show(message: string, tone: ToastTone = 'success'): void {
|
|
20
|
+
this.current = { id: this.nextId++, message, tone };
|
|
21
|
+
this.notify();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The toast to display, or null when none is showing. */
|
|
25
|
+
public latest(): Toast | null {
|
|
26
|
+
return this.current;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Hides the toast with this id; a newer toast stays. */
|
|
30
|
+
public dismiss(id: number): void {
|
|
31
|
+
if (this.current?.id !== id) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.current = null;
|
|
35
|
+
this.notify();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type UserErrorStatus = 400 | 401 | 403 | 404 | 409;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An error the user can fix or should be told about. Its message is shown to them word for word,
|
|
5
|
+
* so write it where the cause is known. Anything that isn't a UserError is treated as a bug.
|
|
6
|
+
*/
|
|
7
|
+
export class UserError extends Error {
|
|
8
|
+
public constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly status: UserErrorStatus = 400,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'UserError';
|
|
14
|
+
}
|
|
15
|
+
}
|