@hyperfixation/core 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/activity.d.ts +73 -0
  3. package/dist/activity.js +84 -0
  4. package/dist/define-app.d.ts +171 -0
  5. package/dist/define-app.js +283 -0
  6. package/dist/index.d.ts +25 -0
  7. package/dist/index.js +19 -0
  8. package/dist/labels.d.ts +41 -0
  9. package/dist/labels.js +62 -0
  10. package/dist/outcomes.d.ts +35 -0
  11. package/dist/outcomes.js +50 -0
  12. package/dist/pages.d.ts +11 -0
  13. package/dist/pages.js +1 -0
  14. package/dist/pause.d.ts +41 -0
  15. package/dist/pause.js +52 -0
  16. package/dist/records.d.ts +67 -0
  17. package/dist/records.js +111 -0
  18. package/dist/registry.d.ts +46 -0
  19. package/dist/registry.js +75 -0
  20. package/dist/resolution.d.ts +57 -0
  21. package/dist/resolution.js +221 -0
  22. package/dist/resolvers.d.ts +23 -0
  23. package/dist/resolvers.js +9 -0
  24. package/dist/schedules.d.ts +40 -0
  25. package/dist/schedules.js +32 -0
  26. package/dist/scorers.d.ts +17 -0
  27. package/dist/scorers.js +3 -0
  28. package/dist/scores.d.ts +80 -0
  29. package/dist/scores.js +113 -0
  30. package/dist/sources.d.ts +13 -0
  31. package/dist/sources.js +3 -0
  32. package/dist/specs.d.ts +12 -0
  33. package/dist/specs.js +7 -0
  34. package/dist/status-route.d.ts +29 -0
  35. package/dist/status-route.js +61 -0
  36. package/dist/status-token.d.ts +19 -0
  37. package/dist/status-token.js +40 -0
  38. package/dist/status.d.ts +45 -0
  39. package/dist/status.js +99 -0
  40. package/dist/step-client.d.ts +8 -0
  41. package/dist/step-client.js +8 -0
  42. package/dist/tasks.d.ts +96 -0
  43. package/dist/tasks.js +151 -0
  44. package/dist/workspace-views.d.ts +143 -0
  45. package/dist/workspace-views.js +0 -0
  46. package/dist/workspace.d.ts +91 -0
  47. package/dist/workspace.js +136 -0
  48. package/package.json +52 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What the workspace reads. Every function here is a control-plane read on the attached pool —
3
+ * plain unlocked SELECTs over the machinery tables and the app's own record table, no lock and
4
+ * no write — and every one is refused from inside a run for the reason `records.archive()`
5
+ * gives: a control-plane pool call made under a step's open transaction is a wait no deadlock
6
+ * detector can see.
7
+ *
8
+ * The views live here and not in `workspace.ts` because that file is the framework-light
9
+ * subpath the template imports: it stays pure and synchronous, and only the types cross over.
10
+ */
11
+ import type { ApprovalDecisionKind, DecideResult } from "@hyperfixation/workflows";
12
+ import type { Pool } from "pg";
13
+ import { type ActivityRow } from "./activity.js";
14
+ import { type LabelRow } from "./labels.js";
15
+ import { type OutcomeRow } from "./outcomes.js";
16
+ import { type RecordDefinition, type StageDefinition } from "./records.js";
17
+ import type { Registry } from "./registry.js";
18
+ import { type TaskRow } from "./tasks.js";
19
+ import { DEFAULT_BOARD_LIMIT, type DraftField } from "./workspace.js";
20
+ export { DEFAULT_BOARD_LIMIT };
21
+ export declare const WORKSPACE_INBOX_OPERATION = "workspace.inbox";
22
+ export declare const WORKSPACE_HOME_OPERATION = "workspace.home";
23
+ export declare const WORKSPACE_BOARD_OPERATION = "workspace.board";
24
+ export declare const WORKSPACE_RECORD_OPERATION = "workspace.record";
25
+ /** The handles and registries every view reads through; `defineApp` supplies them. */
26
+ export interface WorkspaceViewDeps {
27
+ pool: Pool;
28
+ records: Registry<RecordDefinition>;
29
+ /** True for an approval type with a registered draft schema — the only kind an edit reaches. */
30
+ hasSchema(type: string): boolean;
31
+ }
32
+ /** One pending approval, with its draft already flattened for whatever renders it. */
33
+ export interface InboxItem {
34
+ approvalId: number;
35
+ runId: string;
36
+ flow: string;
37
+ key: string;
38
+ type: string;
39
+ recordType: string | null;
40
+ recordId: string | null;
41
+ /** The record's `displayColumn`; null when the approval names no record or the row is gone. */
42
+ recordTitle: string | null;
43
+ assigneeId: string | null;
44
+ createdAt: Date;
45
+ expiresAt: Date | null;
46
+ draft: unknown;
47
+ fields: DraftField[];
48
+ /** False when the approval's type registers no schema, which is what refuses every edit. */
49
+ editable: boolean;
50
+ }
51
+ export interface InboxOptions {
52
+ userId: string;
53
+ /** An admin sees every pending approval, not only their own and the unassigned ones. */
54
+ admin?: boolean;
55
+ }
56
+ export interface InboxView {
57
+ items: InboxItem[];
58
+ /** Counted over what this call returned, so an admin's counts are still their own. */
59
+ mine: number;
60
+ unassigned: number;
61
+ }
62
+ export interface HomeOptions {
63
+ userId: string;
64
+ }
65
+ export interface ReviewQueueCount {
66
+ source: string;
67
+ count: number;
68
+ }
69
+ export interface HomeView {
70
+ /** The user's own and the unassigned pending approvals — never someone else's. */
71
+ approvals: InboxItem[];
72
+ tasks: TaskRow[];
73
+ reviewQueue: ReviewQueueCount[];
74
+ }
75
+ export interface BoardOptions {
76
+ limit?: number;
77
+ }
78
+ export interface BoardCard {
79
+ id: string;
80
+ title: string | null;
81
+ stage: string | null;
82
+ score: number | null;
83
+ updatedAt: Date | null;
84
+ }
85
+ export interface BoardColumn {
86
+ stage: StageDefinition;
87
+ cards: BoardCard[];
88
+ }
89
+ export interface BoardView {
90
+ record: RecordDefinition;
91
+ columns: BoardColumn[];
92
+ /** Cards whose `stage` is null or names no registered stage. */
93
+ other: BoardCard[];
94
+ /** The limit this read used, whether the caller gave one or not. */
95
+ limit: number;
96
+ /** True when the table holds more unarchived rows than `limit` returned. */
97
+ truncated: boolean;
98
+ }
99
+ /** One run's writes on a record, in order. `runId` null is the manual group. */
100
+ export interface TimelineGroup {
101
+ runId: string | null;
102
+ flow: string | null;
103
+ startedAt: Date | null;
104
+ entries: ActivityRow[];
105
+ }
106
+ export interface RecordView {
107
+ record: RecordDefinition;
108
+ id: string;
109
+ title: string | null;
110
+ /** The app table's own row, column names as the table spells them. */
111
+ row: Record<string, unknown>;
112
+ archivedAt: Date | null;
113
+ timeline: TimelineGroup[];
114
+ labels: LabelRow[];
115
+ outcomes: OutcomeRow[];
116
+ tasks: TaskRow[];
117
+ pendingApprovals: InboxItem[];
118
+ }
119
+ /** `DecideOptions` without `via`: the workspace is the web, and only the web. */
120
+ export interface WorkspaceDecideOptions {
121
+ ids: number[];
122
+ decision: ApprovalDecisionKind;
123
+ /** Per-approval replacement drafts, parsed against the type's schema by `decide()`. */
124
+ edits?: Record<number, unknown>;
125
+ /** The client's own token, generated once per form mount, so a double submit replays. */
126
+ decisionKey: string;
127
+ userId?: string | null;
128
+ /** Whether the session holds the admin role; without it an assigned row refuses. */
129
+ admin?: boolean;
130
+ }
131
+ /** The reads `app.workspace` adds to the descriptors; all of them control-plane. */
132
+ export interface WorkspaceViews {
133
+ inbox(options: InboxOptions): Promise<InboxView>;
134
+ home(options: HomeOptions): Promise<HomeView>;
135
+ board(recordType: string, options?: BoardOptions): Promise<BoardView>;
136
+ /** Undefined when the table holds no row with that id; an archived record still resolves. */
137
+ record(recordType: string, id: string | number): Promise<RecordView | undefined>;
138
+ decide(options: WorkspaceDecideOptions): Promise<DecideResult>;
139
+ }
140
+ export declare function workspaceInbox(deps: WorkspaceViewDeps, options: InboxOptions): Promise<InboxView>;
141
+ export declare function workspaceHome(deps: WorkspaceViewDeps, options: HomeOptions): Promise<HomeView>;
142
+ export declare function workspaceBoard(deps: WorkspaceViewDeps, recordType: string, options?: BoardOptions): Promise<BoardView>;
143
+ export declare function workspaceRecord(deps: WorkspaceViewDeps, recordType: string, id: string | number): Promise<RecordView | undefined>;
Binary file
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The workspace's descriptors, and the reason they are a second entry point: the workspace is
3
+ * paths, nav items and flattened drafts — no component, no `react`, nothing the template cannot
4
+ * render its own way. Everything here is pure and synchronous; the guard is the template's, on
5
+ * the layout that renders these, because a workspace 404 and a workspace sign-in look the same
6
+ * to a stranger only if one guard covers every route.
7
+ */
8
+ import type { ActivityRow } from "./activity.js";
9
+ import type { LabelRow } from "./labels.js";
10
+ import type { OutcomeRow } from "./outcomes.js";
11
+ import type { PageDefinition } from "./pages.js";
12
+ import type { RecordDefinition, StageDefinition } from "./records.js";
13
+ import type { Registry } from "./registry.js";
14
+ import type { TaskRow } from "./tasks.js";
15
+ import type { WorkspaceViews } from "./workspace-views.js";
16
+ export type { ActivityRow, LabelRow, OutcomeRow, PageDefinition, RecordDefinition, Registry, StageDefinition, TaskRow, };
17
+ /**
18
+ * The reads themselves are `@hyperfixation/core`'s — they need the pool — but their types are
19
+ * the template's to name, so they are re-exported here with the descriptors.
20
+ */
21
+ export type { BoardCard, BoardColumn, BoardOptions, BoardView, HomeOptions, HomeView, InboxItem, InboxOptions, InboxView, RecordView, ReviewQueueCount, TimelineGroup, WorkspaceDecideOptions, WorkspaceViews, } from "./workspace-views.js";
22
+ /** Where the template mounts the workspace. Only the default; `route()` takes what it is given. */
23
+ export declare const WORKSPACE_BASE_PATH = "/w";
24
+ /**
25
+ * How many cards a board reads per record type before it stops. Declared here rather than beside
26
+ * `workspaceBoard` so a template can name the default it is about to override without importing
27
+ * the reads — `workspace-views.ts` re-exports it for `.`.
28
+ */
29
+ export declare const DEFAULT_BOARD_LIMIT = 500;
30
+ export type WorkspaceRoute = {
31
+ kind: "home";
32
+ } | {
33
+ kind: "inbox";
34
+ } | {
35
+ kind: "approval";
36
+ id: number;
37
+ } | {
38
+ kind: "board";
39
+ record: RecordDefinition;
40
+ } | {
41
+ kind: "record";
42
+ record: RecordDefinition;
43
+ id: string;
44
+ } | {
45
+ kind: "page";
46
+ page: PageDefinition;
47
+ };
48
+ export interface WorkspaceNavItem {
49
+ readonly path: string;
50
+ readonly title: string;
51
+ }
52
+ /** The two registries the workspace reads. `defineApp` passes its own. */
53
+ export interface WorkspaceRegistries {
54
+ readonly records: Registry<RecordDefinition>;
55
+ readonly pages: Registry<PageDefinition>;
56
+ }
57
+ export interface AppWorkspace extends WorkspaceViews {
58
+ /**
59
+ * The catch-all's body: the segments below the mount point, resolved. `undefined` is a path the
60
+ * workspace does not serve. No guard and no `await` — unlike the admin's `route()`, this one
61
+ * resolves a path and nothing else.
62
+ */
63
+ route(path?: string | readonly string[]): WorkspaceRoute | undefined;
64
+ nav(): WorkspaceNavItem[];
65
+ }
66
+ export declare function approvalPath(id: number): string;
67
+ export declare function workspaceRoute(registries: WorkspaceRegistries, path?: string | readonly string[]): WorkspaceRoute | undefined;
68
+ export declare function workspaceNav(registries: WorkspaceRegistries): WorkspaceNavItem[];
69
+ /** One row of a draft, ready to be shown or edited. `path` is also an edit form's field name. */
70
+ export interface DraftField {
71
+ /** Dotted through objects and bracketed through arrays: `contacts[0].email`. */
72
+ readonly path: string;
73
+ /**
74
+ * The walk from the draft root: object keys verbatim, array indexes as numbers. `path` is a
75
+ * display string and two different leaves can share one — `{"a.b": 1}` and `{a: {b: 2}}` both
76
+ * read `a.b` — so anything writing a value back follows this instead. Empty for a bare scalar
77
+ * draft, which is its own root.
78
+ */
79
+ readonly segments: readonly (string | number)[];
80
+ readonly label: string;
81
+ readonly value: string;
82
+ }
83
+ /**
84
+ * A draft — an approval's `payload`, model output — flattened to rows. The values are the draft's
85
+ * own text, unescaped and unmarked-up: escaping belongs to whatever renders them, and a draft
86
+ * holding `<img src=x>` must reach the renderer as that literal string rather than as something
87
+ * this function decided was safe.
88
+ *
89
+ * A container contributes no row of its own, so an empty object or array flattens to nothing.
90
+ */
91
+ export declare function draftFields(draft: unknown): DraftField[];
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The workspace's descriptors, and the reason they are a second entry point: the workspace is
3
+ * paths, nav items and flattened drafts — no component, no `react`, nothing the template cannot
4
+ * render its own way. Everything here is pure and synchronous; the guard is the template's, on
5
+ * the layout that renders these, because a workspace 404 and a workspace sign-in look the same
6
+ * to a stranger only if one guard covers every route.
7
+ */
8
+ /** Where the template mounts the workspace. Only the default; `route()` takes what it is given. */
9
+ export const WORKSPACE_BASE_PATH = "/w";
10
+ /**
11
+ * How many cards a board reads per record type before it stops. Declared here rather than beside
12
+ * `workspaceBoard` so a template can name the default it is about to override without importing
13
+ * the reads — `workspace-views.ts` re-exports it for `.`.
14
+ */
15
+ export const DEFAULT_BOARD_LIMIT = 500;
16
+ export function approvalPath(id) {
17
+ return `${WORKSPACE_BASE_PATH}/approvals/${id}`;
18
+ }
19
+ function segmentsOf(path) {
20
+ // A catch-all already hands back the segments below the mount, so an array is taken as-is — a
21
+ // record type named `w` stays reachable. Only a whole pathname has the mount on the front.
22
+ if (typeof path !== "string")
23
+ return (path ?? []).filter((segment) => segment.length > 0);
24
+ const segments = path.split("/").filter((segment) => segment.length > 0);
25
+ return segments[0] === WORKSPACE_BASE_PATH.slice(1) ? segments.slice(1) : segments;
26
+ }
27
+ /** `hf_approval.id` is a bigint identity, so anything else in the slot is not an approval. */
28
+ function approvalIdOf(segment) {
29
+ if (!/^[1-9][0-9]*$/.test(segment))
30
+ return undefined;
31
+ const id = Number(segment);
32
+ return Number.isSafeInteger(id) ? id : undefined;
33
+ }
34
+ export function workspaceRoute(registries, path) {
35
+ const segments = segmentsOf(path);
36
+ if (segments.length === 0)
37
+ return { kind: "home" };
38
+ if (segments[0] === "approvals") {
39
+ if (segments.length === 1)
40
+ return { kind: "inbox" };
41
+ if (segments.length > 2)
42
+ return undefined;
43
+ const id = approvalIdOf(segments[1]);
44
+ return id === undefined ? undefined : { kind: "approval", id };
45
+ }
46
+ if (segments.length <= 2) {
47
+ const record = registries.records.get(segments[0]);
48
+ if (record !== undefined) {
49
+ const id = segments[1];
50
+ return id === undefined ? { kind: "board", record } : { kind: "record", record, id };
51
+ }
52
+ }
53
+ // Pages are keyed by their whole path, which is also the `href` `nav()` hands the template, so
54
+ // the lookup puts the mount back on the front of the segments it was given.
55
+ const page = registries.pages.get([WORKSPACE_BASE_PATH, ...segments].join("/"));
56
+ return page === undefined ? undefined : { kind: "page", page };
57
+ }
58
+ export function workspaceNav(registries) {
59
+ return [
60
+ { path: WORKSPACE_BASE_PATH, title: "Home" },
61
+ { path: `${WORKSPACE_BASE_PATH}/approvals`, title: "Inbox" },
62
+ ...registries.records.all().map((record) => ({
63
+ path: `${WORKSPACE_BASE_PATH}/${record.recordType}`,
64
+ title: record.title ?? record.recordType,
65
+ })),
66
+ ...registries.pages
67
+ .all()
68
+ .filter((page) => page.nav === true)
69
+ .map((page) => ({ path: page.path, title: page.title })),
70
+ ];
71
+ }
72
+ function isPlainRecord(value) {
73
+ if (typeof value !== "object" || value === null || Array.isArray(value))
74
+ return false;
75
+ const proto = Object.getPrototypeOf(value);
76
+ return proto === Object.prototype || proto === null;
77
+ }
78
+ /**
79
+ * A leaf is always text, never `null` or a number: the template renders these as strings and an
80
+ * edit form posts them back as strings, so the conversion happens once, here.
81
+ */
82
+ function leafText(value) {
83
+ if (typeof value === "string")
84
+ return value;
85
+ if (value === null || value === undefined)
86
+ return "";
87
+ if (value instanceof Date)
88
+ return value.toISOString();
89
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
90
+ return String(value);
91
+ }
92
+ // A function, a symbol or an exotic object: nothing a JSON draft holds, and nothing worth
93
+ // guessing a rendering for.
94
+ return "";
95
+ }
96
+ function labelOf(key) {
97
+ const words = key
98
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
99
+ .split(/[\s_-]+/)
100
+ .filter((word) => word.length > 0)
101
+ .map((word) => word.toLowerCase());
102
+ const sentence = words.join(" ");
103
+ return sentence.length === 0 ? key : sentence[0].toUpperCase() + sentence.slice(1);
104
+ }
105
+ function flatten(value, path, segments, label, into) {
106
+ if (Array.isArray(value)) {
107
+ value.forEach((item, index) => {
108
+ flatten(item, `${path}[${index}]`, [...segments, index], `${label} ${index + 1}`, into);
109
+ });
110
+ return;
111
+ }
112
+ if (isPlainRecord(value)) {
113
+ for (const [key, nested] of Object.entries(value)) {
114
+ flatten(nested, path === "" ? key : `${path}.${key}`, [...segments, key], labelOf(key), into);
115
+ }
116
+ return;
117
+ }
118
+ into.push({ path, segments, label, value: leafText(value) });
119
+ }
120
+ /**
121
+ * A draft — an approval's `payload`, model output — flattened to rows. The values are the draft's
122
+ * own text, unescaped and unmarked-up: escaping belongs to whatever renders them, and a draft
123
+ * holding `<img src=x>` must reach the renderer as that literal string rather than as something
124
+ * this function decided was safe.
125
+ *
126
+ * A container contributes no row of its own, so an empty object or array flattens to nothing.
127
+ */
128
+ export function draftFields(draft) {
129
+ if (Array.isArray(draft) || isPlainRecord(draft)) {
130
+ const fields = [];
131
+ flatten(draft, "", [], "Value", fields);
132
+ return fields;
133
+ }
134
+ // A bare scalar draft has no key to name it, and `value` is the field name a form would post.
135
+ return [{ path: "value", segments: [], label: "Value", value: leafText(draft) }];
136
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@hyperfixation/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "defineApp, registries, resolution, records, activity, tasks, labels, outcomes, and the status endpoint",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/grahamlutz/hyperfixation-core.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./workspace": {
22
+ "types": "./dist/workspace.d.ts",
23
+ "default": "./dist/workspace.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "!dist/**/*.test.*"
29
+ ],
30
+ "dependencies": {
31
+ "@dbos-inc/dbos-sdk": "4.27.6",
32
+ "@hyperfixation/db": "0.1.0",
33
+ "@hyperfixation/workflows": "0.1.0",
34
+ "pg": "^8.23.0"
35
+ },
36
+ "devDependencies": {
37
+ "@hyperfixation/eslint-config": "0.1.0",
38
+ "@hyperfixation/testing": "0.1.0",
39
+ "@microsoft/api-extractor": "^7.59.1",
40
+ "@types/pg": "^8.23.1",
41
+ "eslint": "^10.10.0",
42
+ "zod": "4.6.5"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "lint": "eslint src",
48
+ "api-extractor": "api-extractor run && api-extractor run -c api-extractor.workspace.json",
49
+ "api-extractor:update": "api-extractor run --local && api-extractor run --local -c api-extractor.workspace.json",
50
+ "test": "vitest run --passWithNoTests"
51
+ }
52
+ }