@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
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export { defineApp, AppNotAttached, NoApplicationVersion, } from "./define-app.js";
2
+ export { createRegistry, DuplicateRegistration, InvalidDefinition, UnknownRegistration, } from "./registry.js";
3
+ export { defineSource } from "./sources.js";
4
+ export { defineResolver } from "./resolvers.js";
5
+ export { bigramDice, fuzzyCandidateStatement, resolveBatch, DEFAULT_RESOLVE_LIMIT, DEFAULT_RESOLVE_MAX_ATTEMPTS, FUZZY_CANDIDATE_LIMIT, } from "./resolution.js";
6
+ export { defineSpec } from "./specs.js";
7
+ export { defineScorer } from "./scorers.js";
8
+ export { defineSchedule, fireSchedule, schedulesDue, } from "./schedules.js";
9
+ export { latestScores, writeScore, writeStepScore, EXISTING_SCORE_STATEMENT, LATEST_SCORES_STATEMENT, WRITE_SCORE_STATEMENT, } from "./scores.js";
10
+ export { assertActivityKind, insertActivity, listActivity, recordActivity, InvalidActivityKind, ACTIVITY_KIND_PATTERN, ACTIVITY_LIST_OPERATION, EXISTING_ACTIVITY_STATEMENT, INSERT_ACTIVITY_STATEMENT, } from "./activity.js";
11
+ export { cancelOpenTasksForRecord, cancelTask, completeTask, createManualTask, createTask, flowOriginRef, listTasks, TASK_CANCEL_OPERATION, TASK_COMPLETE_OPERATION, TASK_CREATE_MANUAL_OPERATION, TASK_LIST_OPERATION, } from "./tasks.js";
12
+ export { addLabel, listLabels, LABEL_ADD_OPERATION, LABEL_LIST_OPERATION, } from "./labels.js";
13
+ export { listOutcomes, recordOutcome, OUTCOME_LIST_OPERATION, OUTCOME_RECORD_OPERATION, } from "./outcomes.js";
14
+ export { pauseApp, resumeApp, PAUSED_MARKER, PAUSE_OPERATION, RESUMED_MARKER, RESUME_OPERATION, } from "./pause.js";
15
+ export { archiveRecord, archiveDecisionKey, assertRecordStages, displayColumnOf, ARCHIVED_MARKER, ARCHIVE_OPERATION, DEFAULT_DISPLAY_COLUMN, } from "./records.js";
16
+ export { appStatus, CORE_VERSION, } from "./status.js";
17
+ export { createStatusHandler, statusRouteOf, STATUS_TOKEN_ACTOR, } from "./status-route.js";
18
+ export { bearerToken, hashStatusToken, statusTokenMatches, STATUS_TOKEN_DIGEST, } from "./status-token.js";
19
+ export { workspaceBoard, workspaceHome, workspaceInbox, workspaceRecord, DEFAULT_BOARD_LIMIT, WORKSPACE_BOARD_OPERATION, WORKSPACE_HOME_OPERATION, WORKSPACE_INBOX_OPERATION, WORKSPACE_RECORD_OPERATION, } from "./workspace-views.js";
@@ -0,0 +1,41 @@
1
+ import { type LabelTarget, type LabelValue, type RecordTable } from "@hyperfixation/db";
2
+ import type { Pool } from "pg";
3
+ import type { Registry } from "./registry.js";
4
+ export declare const LABEL_ADD_OPERATION = "labels.add";
5
+ export declare const LABEL_LIST_OPERATION = "labels.list";
6
+ export interface LabelAddOptions {
7
+ recordType: string;
8
+ recordId: string | number;
9
+ target: LabelTarget;
10
+ /** Which score or draft the label is about; null for a label on the record itself. */
11
+ targetId?: string | number;
12
+ value: LabelValue;
13
+ correction?: unknown;
14
+ userId?: string;
15
+ }
16
+ export interface LabelListOptions {
17
+ recordType: string;
18
+ recordId: string | number;
19
+ }
20
+ export interface LabelRow {
21
+ id: number;
22
+ recordType: string;
23
+ recordId: string;
24
+ target: LabelTarget;
25
+ targetId: string | null;
26
+ value: LabelValue;
27
+ correction: unknown;
28
+ userId: string | null;
29
+ createdAt: Date;
30
+ }
31
+ /**
32
+ * A human's feedback on a score, a draft or the record, always from the web: `hf_label` is what
33
+ * the scorer's next spec version is argued from, and nothing in a run produces one.
34
+ *
35
+ * The record type is required and registered — the row's `record_type` is NOT NULL, and an
36
+ * unregistered value is what E002 refuses at the next boot.
37
+ */
38
+ export declare function addLabel(pool: Pool, records: Registry<RecordTable>, options: LabelAddOptions): Promise<{
39
+ id: number;
40
+ }>;
41
+ export declare function listLabels(pool: Pool, options: LabelListOptions): Promise<LabelRow[]>;
package/dist/labels.js ADDED
@@ -0,0 +1,62 @@
1
+ import { assertNotInWorkflow, controlPlaneTx, } from "@hyperfixation/db";
2
+ import { insertActivity } from "./activity.js";
3
+ export const LABEL_ADD_OPERATION = "labels.add";
4
+ export const LABEL_LIST_OPERATION = "labels.list";
5
+ const INSERT_LABEL_STATEMENT = "INSERT INTO hf_label (record_type, record_id, target, target_id, value, correction, user_id) " +
6
+ "VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7) RETURNING id";
7
+ const LIST_LABELS_STATEMENT = "SELECT id, record_type, record_id, target, target_id, value, correction, user_id, created_at " +
8
+ "FROM hf_label WHERE record_type = $1 AND record_id = $2 ORDER BY created_at, id";
9
+ /**
10
+ * A human's feedback on a score, a draft or the record, always from the web: `hf_label` is what
11
+ * the scorer's next spec version is argued from, and nothing in a run produces one.
12
+ *
13
+ * The record type is required and registered — the row's `record_type` is NOT NULL, and an
14
+ * unregistered value is what E002 refuses at the next boot.
15
+ */
16
+ export async function addLabel(pool, records, options) {
17
+ records.require(options.recordType);
18
+ const recordId = String(options.recordId);
19
+ return controlPlaneTx(pool, { operation: LABEL_ADD_OPERATION }, async (work) => {
20
+ const { rows } = await work.query(INSERT_LABEL_STATEMENT, [
21
+ options.recordType,
22
+ recordId,
23
+ options.target,
24
+ options.targetId === undefined ? null : String(options.targetId),
25
+ options.value,
26
+ options.correction === undefined ? null : JSON.stringify(options.correction),
27
+ options.userId ?? null,
28
+ ]);
29
+ const id = Number(rows[0].id);
30
+ await insertActivity(work, {
31
+ recordType: options.recordType,
32
+ recordId,
33
+ kind: "label.added",
34
+ actorId: options.userId ?? null,
35
+ meta: {
36
+ labelId: id,
37
+ target: options.target,
38
+ targetId: options.targetId === undefined ? null : String(options.targetId),
39
+ value: options.value,
40
+ },
41
+ });
42
+ return { id };
43
+ });
44
+ }
45
+ export async function listLabels(pool, options) {
46
+ assertNotInWorkflow(LABEL_LIST_OPERATION);
47
+ const { rows } = await pool.query(LIST_LABELS_STATEMENT, [
48
+ options.recordType,
49
+ String(options.recordId),
50
+ ]);
51
+ return rows.map((row) => ({
52
+ id: Number(row.id),
53
+ recordType: row.record_type,
54
+ recordId: row.record_id,
55
+ target: row.target,
56
+ targetId: row.target_id,
57
+ value: row.value,
58
+ correction: row.correction,
59
+ userId: row.user_id,
60
+ createdAt: row.created_at,
61
+ }));
62
+ }
@@ -0,0 +1,35 @@
1
+ import { type RecordTable } from "@hyperfixation/db";
2
+ import type { Pool } from "pg";
3
+ import type { Registry } from "./registry.js";
4
+ export declare const OUTCOME_RECORD_OPERATION = "outcomes.record";
5
+ export declare const OUTCOME_LIST_OPERATION = "outcomes.list";
6
+ export interface OutcomeRecordOptions {
7
+ recordType: string;
8
+ recordId: string | number;
9
+ /** The app's own vocabulary — what happened, not how it was scored. */
10
+ outcome: string;
11
+ /** When it happened, if that is not now; an outcome is often learned after the fact. */
12
+ at?: Date;
13
+ notes?: string;
14
+ userId?: string;
15
+ }
16
+ export interface OutcomeListOptions {
17
+ recordType: string;
18
+ recordId: string | number;
19
+ }
20
+ export interface OutcomeRow {
21
+ id: number;
22
+ recordType: string;
23
+ recordId: string;
24
+ outcome: string;
25
+ at: Date;
26
+ notes: string | null;
27
+ }
28
+ /**
29
+ * What became of a record, from the web. Unlike a score it is never derived: a flow that thinks
30
+ * it knows an outcome is asserting a fact about the world, which is a human's to record.
31
+ */
32
+ export declare function recordOutcome(pool: Pool, records: Registry<RecordTable>, options: OutcomeRecordOptions): Promise<{
33
+ id: number;
34
+ }>;
35
+ export declare function listOutcomes(pool: Pool, options: OutcomeListOptions): Promise<OutcomeRow[]>;
@@ -0,0 +1,50 @@
1
+ import { assertNotInWorkflow, controlPlaneTx } from "@hyperfixation/db";
2
+ import { insertActivity } from "./activity.js";
3
+ export const OUTCOME_RECORD_OPERATION = "outcomes.record";
4
+ export const OUTCOME_LIST_OPERATION = "outcomes.list";
5
+ const INSERT_OUTCOME_STATEMENT = "INSERT INTO hf_outcome (record_type, record_id, outcome, at, notes) " +
6
+ "VALUES ($1, $2, $3, COALESCE($4, now()), $5) RETURNING id";
7
+ const LIST_OUTCOMES_STATEMENT = "SELECT id, record_type, record_id, outcome, at, notes FROM hf_outcome " +
8
+ "WHERE record_type = $1 AND record_id = $2 ORDER BY at, id";
9
+ /**
10
+ * What became of a record, from the web. Unlike a score it is never derived: a flow that thinks
11
+ * it knows an outcome is asserting a fact about the world, which is a human's to record.
12
+ */
13
+ export async function recordOutcome(pool, records, options) {
14
+ records.require(options.recordType);
15
+ const recordId = String(options.recordId);
16
+ return controlPlaneTx(pool, { operation: OUTCOME_RECORD_OPERATION }, async (work) => {
17
+ const { rows } = await work.query(INSERT_OUTCOME_STATEMENT, [
18
+ options.recordType,
19
+ recordId,
20
+ options.outcome,
21
+ options.at ?? null,
22
+ options.notes ?? null,
23
+ ]);
24
+ const id = Number(rows[0].id);
25
+ await insertActivity(work, {
26
+ recordType: options.recordType,
27
+ recordId,
28
+ kind: "outcome.recorded",
29
+ actorId: options.userId ?? null,
30
+ body: options.notes ?? null,
31
+ meta: { outcomeId: id, outcome: options.outcome },
32
+ });
33
+ return { id };
34
+ });
35
+ }
36
+ export async function listOutcomes(pool, options) {
37
+ assertNotInWorkflow(OUTCOME_LIST_OPERATION);
38
+ const { rows } = await pool.query(LIST_OUTCOMES_STATEMENT, [
39
+ options.recordType,
40
+ String(options.recordId),
41
+ ]);
42
+ return rows.map((row) => ({
43
+ id: Number(row.id),
44
+ recordType: row.record_type,
45
+ recordId: row.record_id,
46
+ outcome: row.outcome,
47
+ at: row.at,
48
+ notes: row.notes,
49
+ }));
50
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A workspace page, keyed by `path`. It says a page exists and what to call it, and nothing
3
+ * about how to draw it: the workspace ships descriptors and the template renders them, so a
4
+ * page carries no component here.
5
+ */
6
+ export interface PageDefinition {
7
+ readonly path: string;
8
+ readonly title: string;
9
+ /** In the workspace nav; a page reached only from another page leaves it out. */
10
+ readonly nav?: boolean;
11
+ }
package/dist/pages.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import type { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { type QueueConcurrency, type ReconcileReport } from "@hyperfixation/workflows";
3
+ import type { Pool } from "pg";
4
+ export declare const PAUSE_OPERATION = "app.pause";
5
+ export declare const RESUME_OPERATION = "app.resume";
6
+ /** One line per transition, carrying who asked for it. */
7
+ export declare const PAUSED_MARKER = "hf-app: paused";
8
+ export declare const RESUMED_MARKER = "hf-app: resumed";
9
+ export interface PauseOptions {
10
+ /** Recorded in `hf_app_state.paused_by` and on the audit row. */
11
+ userId?: string | null;
12
+ reason?: string;
13
+ }
14
+ export interface PauseResult {
15
+ paused: true;
16
+ queues: QueueConcurrency[];
17
+ }
18
+ export interface ResumeOptions extends PauseOptions {
19
+ /** What `reconcile()` treats as the live version; `defineApp` supplies the app's. */
20
+ applicationVersion: string;
21
+ }
22
+ export interface ResumeResult {
23
+ paused: false;
24
+ queues: QueueConcurrency[];
25
+ /** The pass that started the next attempt of every run the pause parked. */
26
+ reconciled: ReconcileReport;
27
+ }
28
+ /**
29
+ * A control-plane operation. The flag goes first and the queues second, in that order for a
30
+ * reason: `hf_app_state.paused` is what the step gate reads, so it is the whole of the
31
+ * correctness, and zero concurrency only stops work being dispatched that the gate would
32
+ * suspend at its first step anyway. A pause whose queue half failed is still a pause.
33
+ */
34
+ export declare function pauseApp(pool: Pool, client: DBOSClient, options?: PauseOptions): Promise<PauseResult>;
35
+ /**
36
+ * The mirror, in the mirrored order: the queues are restored only once the flag is clear, so
37
+ * there is no window in which a step is dispatched into an app that still reads as paused and
38
+ * suspends it again. `reconcile()` last, because step (3) is what starts the next attempt of
39
+ * every run the pause parked, and it only moves a `paused` run while the app is not paused.
40
+ */
41
+ export declare function resumeApp(pool: Pool, client: DBOSClient, options: ResumeOptions): Promise<ResumeResult>;
package/dist/pause.js ADDED
@@ -0,0 +1,52 @@
1
+ import { AppStateMissing, controlPlaneTx, SET_APP_PAUSED_STATEMENT } from "@hyperfixation/db";
2
+ import { reconcile, setPausedQueueConcurrency, } from "@hyperfixation/workflows";
3
+ export const PAUSE_OPERATION = "app.pause";
4
+ export const RESUME_OPERATION = "app.resume";
5
+ /** One line per transition, carrying who asked for it. */
6
+ export const PAUSED_MARKER = "hf-app: paused";
7
+ export const RESUMED_MARKER = "hf-app: resumed";
8
+ const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
9
+ "VALUES ($1, $2, 'hf_app_state', '1', $3::jsonb)";
10
+ /**
11
+ * A control-plane operation. The flag goes first and the queues second, in that order for a
12
+ * reason: `hf_app_state.paused` is what the step gate reads, so it is the whole of the
13
+ * correctness, and zero concurrency only stops work being dispatched that the gate would
14
+ * suspend at its first step anyway. A pause whose queue half failed is still a pause.
15
+ */
16
+ export async function pauseApp(pool, client, options = {}) {
17
+ await setPaused(pool, PAUSE_OPERATION, true, options);
18
+ const queues = await setPausedQueueConcurrency(client, true);
19
+ console.info(PAUSED_MARKER, JSON.stringify({ by: options.userId ?? null, queues }));
20
+ return { paused: true, queues };
21
+ }
22
+ /**
23
+ * The mirror, in the mirrored order: the queues are restored only once the flag is clear, so
24
+ * there is no window in which a step is dispatched into an app that still reads as paused and
25
+ * suspends it again. `reconcile()` last, because step (3) is what starts the next attempt of
26
+ * every run the pause parked, and it only moves a `paused` run while the app is not paused.
27
+ */
28
+ export async function resumeApp(pool, client, options) {
29
+ await setPaused(pool, RESUME_OPERATION, false, options);
30
+ const queues = await setPausedQueueConcurrency(client, false);
31
+ console.info(RESUMED_MARKER, JSON.stringify({ by: options.userId ?? null, queues }));
32
+ const reconciled = await reconcile(pool, client, {
33
+ applicationVersion: options.applicationVersion,
34
+ });
35
+ return { paused: false, queues, reconciled };
36
+ }
37
+ /** The flag and its audit row in one tag-asserted transaction; the audit insert is fatal. */
38
+ async function setPaused(pool, operation, paused, options) {
39
+ await controlPlaneTx(pool, { operation }, async (client) => {
40
+ const written = await client.query(SET_APP_PAUSED_STATEMENT, [
41
+ paused,
42
+ paused ? (options.userId ?? null) : null,
43
+ ]);
44
+ if (written.rowCount !== 1)
45
+ throw new AppStateMissing(operation);
46
+ await client.query(AUDIT_STATEMENT, [
47
+ options.userId ?? null,
48
+ paused ? "app.paused" : "app.resumed",
49
+ JSON.stringify({ reason: options.reason ?? null }),
50
+ ]);
51
+ });
52
+ }
@@ -0,0 +1,67 @@
1
+ import type { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { type RecordTable } from "@hyperfixation/db";
3
+ import type { Pool } from "pg";
4
+ import { type Registry } from "./registry.js";
5
+ export declare const ARCHIVE_OPERATION = "records.archive";
6
+ /** The `displayColumn` a record type gets when it names none: the mixin's own name column. */
7
+ export declare const DEFAULT_DISPLAY_COLUMN = "normalized_name";
8
+ /** One board column. `name` is the value the mixin's `stage` carries; `title` is the heading. */
9
+ export interface StageDefinition {
10
+ readonly name: string;
11
+ readonly title: string;
12
+ }
13
+ /**
14
+ * What the workspace needs to know about a record type, on top of the two strings the delete
15
+ * guard and every machinery row already need. All of it is optional: a bare `RecordTable` is
16
+ * still a valid registration, and the workspace falls back to `recordType` and
17
+ * `DEFAULT_DISPLAY_COLUMN`.
18
+ */
19
+ export interface RecordDefinition extends RecordTable {
20
+ /** What the nav and the board call this type; `recordType` when absent. */
21
+ readonly title?: string;
22
+ /** The column the workspace shows as a record's name. */
23
+ readonly displayColumn?: string;
24
+ /** The board's columns, in board order. A `stage` outside the list gets an "Other" column. */
25
+ readonly stages?: readonly StageDefinition[];
26
+ }
27
+ export declare function displayColumnOf(definition: RecordDefinition): string;
28
+ /**
29
+ * Refused at registration rather than at render: a duplicate stage name would give the board two
30
+ * columns competing for the same rows, and which one won would depend on iteration order.
31
+ */
32
+ export declare function assertRecordStages(definition: RecordDefinition): void;
33
+ /** One line per record archived, carrying what it took down with it. */
34
+ export declare const ARCHIVED_MARKER = "hf-records: archived";
35
+ export interface ArchiveOptions {
36
+ recordType: string;
37
+ recordId: string | number;
38
+ userId?: string | null;
39
+ reason?: string;
40
+ }
41
+ export interface ArchiveResult {
42
+ recordType: string;
43
+ recordId: string;
44
+ /** False when the record was already archived; archiving twice writes nothing the second time. */
45
+ archived: boolean;
46
+ /** The approvals this archive cancelled, each through `decide()` and its own bump. */
47
+ cancelledApprovals: number[];
48
+ /** The record's open tasks, cancelled in the same transaction as the record itself. */
49
+ cancelledTasks: number[];
50
+ }
51
+ /** Stable per approval, so an archive retried after a crash replays instead of deciding twice. */
52
+ export declare function archiveDecisionKey(recordType: string, recordId: string, approvalId: number): string;
53
+ /**
54
+ * A **control-plane operation**, and the reason `assertNotInWorkflow()` exists at all
55
+ * (round-3 finding 2): called from inside a step's open `ctx.tx` on the same run, the
56
+ * `FOR UPDATE` this takes on `hf_run` through `decide()` would wait on the step's own
57
+ * `FOR SHARE` while the step waits on this call — a cycle whose second half is a JS `await`,
58
+ * which Postgres's deadlock detector cannot see. So it is refused from inside any run, before
59
+ * a single statement is issued. A flow that wants a record archived creates a task.
60
+ *
61
+ * The approvals go first and the record second: cancelling an approval bumps its run, so the
62
+ * flow waiting on it resumes and sees the cancellation rather than the record vanishing
63
+ * underneath it. Each approval is its own `decide()` call — a batch would let one bad row
64
+ * strand the rest — and each carries a `decisionKey` derived from the record, so an archive
65
+ * retried after a crash replays the decisions it already made.
66
+ */
67
+ export declare function archiveRecord(pool: Pool, client: DBOSClient, records: Registry<RecordTable>, options: ArchiveOptions): Promise<ArchiveResult>;
@@ -0,0 +1,111 @@
1
+ import { assertNotInWorkflow, controlPlaneTx, quoteIdent, } from "@hyperfixation/db";
2
+ import { decide } from "@hyperfixation/workflows";
3
+ import { insertActivity } from "./activity.js";
4
+ import { InvalidDefinition } from "./registry.js";
5
+ import { cancelOpenTasksForRecord } from "./tasks.js";
6
+ export const ARCHIVE_OPERATION = "records.archive";
7
+ /** The `displayColumn` a record type gets when it names none: the mixin's own name column. */
8
+ export const DEFAULT_DISPLAY_COLUMN = "normalized_name";
9
+ export function displayColumnOf(definition) {
10
+ return definition.displayColumn ?? DEFAULT_DISPLAY_COLUMN;
11
+ }
12
+ /**
13
+ * Refused at registration rather than at render: a duplicate stage name would give the board two
14
+ * columns competing for the same rows, and which one won would depend on iteration order.
15
+ */
16
+ export function assertRecordStages(definition) {
17
+ const seen = new Set();
18
+ for (const stage of definition.stages ?? []) {
19
+ if (seen.has(stage.name)) {
20
+ throw new InvalidDefinition("record type", definition.recordType, `lists the stage ${JSON.stringify(stage.name)} twice`);
21
+ }
22
+ seen.add(stage.name);
23
+ }
24
+ }
25
+ /** One line per record archived, carrying what it took down with it. */
26
+ export const ARCHIVED_MARKER = "hf-records: archived";
27
+ /**
28
+ * Pending approvals on the record being archived. Read unlocked — `decide()` locks what it
29
+ * decides, and an approval someone decided between this read and that lock is simply not
30
+ * pending by then and refuses the batch, which is the right answer.
31
+ */
32
+ const PENDING_FOR_RECORD_STATEMENT = "SELECT id FROM hf_approval WHERE record_type = $1 AND record_id = $2 AND status = 'pending' " +
33
+ "ORDER BY id";
34
+ const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) VALUES ($1, 'record.archived', $2, $3, $4::jsonb)";
35
+ /** Stable per approval, so an archive retried after a crash replays instead of deciding twice. */
36
+ export function archiveDecisionKey(recordType, recordId, approvalId) {
37
+ return `archive:${recordType}:${recordId}:${approvalId}`;
38
+ }
39
+ /**
40
+ * A **control-plane operation**, and the reason `assertNotInWorkflow()` exists at all
41
+ * (round-3 finding 2): called from inside a step's open `ctx.tx` on the same run, the
42
+ * `FOR UPDATE` this takes on `hf_run` through `decide()` would wait on the step's own
43
+ * `FOR SHARE` while the step waits on this call — a cycle whose second half is a JS `await`,
44
+ * which Postgres's deadlock detector cannot see. So it is refused from inside any run, before
45
+ * a single statement is issued. A flow that wants a record archived creates a task.
46
+ *
47
+ * The approvals go first and the record second: cancelling an approval bumps its run, so the
48
+ * flow waiting on it resumes and sees the cancellation rather than the record vanishing
49
+ * underneath it. Each approval is its own `decide()` call — a batch would let one bad row
50
+ * strand the rest — and each carries a `decisionKey` derived from the record, so an archive
51
+ * retried after a crash replays the decisions it already made.
52
+ */
53
+ export async function archiveRecord(pool, client, records, options) {
54
+ assertNotInWorkflow(ARCHIVE_OPERATION);
55
+ const recordId = String(options.recordId);
56
+ const { table } = records.require(options.recordType);
57
+ const pending = await pool.query(PENDING_FOR_RECORD_STATEMENT, [
58
+ options.recordType,
59
+ recordId,
60
+ ]);
61
+ const cancelledApprovals = [];
62
+ for (const row of pending.rows) {
63
+ const approvalId = Number(row.id);
64
+ const result = await decide(pool, client, {
65
+ ids: [approvalId],
66
+ decision: "cancelled",
67
+ via: "archive",
68
+ decisionKey: archiveDecisionKey(options.recordType, recordId, approvalId),
69
+ userId: options.userId ?? null,
70
+ });
71
+ cancelledApprovals.push(...result.decided.map((decided) => decided.approvalId));
72
+ }
73
+ // The tasks go with the record and nothing else does: `hf_activity`, `hf_label` and
74
+ // `hf_outcome` are the record's history, and archiving is not a deletion.
75
+ const { archived, cancelledTasks } = await controlPlaneTx(pool, { operation: ARCHIVE_OPERATION }, async (work) => {
76
+ // `archived_at` is the record mixin's column; a table registered as a record type without
77
+ // it fails here by name rather than by being quietly skipped.
78
+ const updated = await work.query(`UPDATE ${quoteIdent(table)} SET archived_at = now() WHERE id = $1 AND archived_at IS NULL`, [recordId]);
79
+ const cancelled = await cancelOpenTasksForRecord(work, options.recordType, recordId);
80
+ const meta = {
81
+ table,
82
+ reason: options.reason ?? null,
83
+ cancelledApprovals,
84
+ cancelledTasks: cancelled,
85
+ alreadyArchived: updated.rowCount === 0,
86
+ };
87
+ await insertActivity(work, {
88
+ recordType: options.recordType,
89
+ recordId,
90
+ kind: "record.archived",
91
+ actorId: options.userId ?? null,
92
+ meta,
93
+ });
94
+ await work.query(AUDIT_STATEMENT, [
95
+ options.userId ?? null,
96
+ options.recordType,
97
+ recordId,
98
+ JSON.stringify(meta),
99
+ ]);
100
+ return { archived: updated.rowCount === 1, cancelledTasks: cancelled };
101
+ });
102
+ const result = {
103
+ recordType: options.recordType,
104
+ recordId,
105
+ archived,
106
+ cancelledApprovals,
107
+ cancelledTasks,
108
+ };
109
+ console.info(ARCHIVED_MARKER, JSON.stringify(result));
110
+ return result;
111
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Every kind of thing an app registers — sources, resolvers, scorers, flows, approval types,
3
+ * channels, record types — is a registry, not a single fixed implementation, and every one of
4
+ * them refuses a second registration under a name it already holds.
5
+ *
6
+ * A duplicate is always a bug and never a harmless overwrite: the name is what a stored row
7
+ * points at, so silently replacing a registration would change what every `hf_run.flow`,
8
+ * `hf_approval.type` and `hf_action_log.channel` row already written means.
9
+ */
10
+ export declare class DuplicateRegistration extends Error {
11
+ readonly kind: string;
12
+ /** Not `name`: that one is `Error`'s, and carries the class name on every error here. */
13
+ readonly registeredName: string;
14
+ constructor(kind: string, registeredName: string);
15
+ }
16
+ export declare class UnknownRegistration extends Error {
17
+ readonly kind: string;
18
+ readonly known: readonly string[];
19
+ constructor(kind: string, name: string, known: readonly string[]);
20
+ }
21
+ /**
22
+ * A definition that is malformed on its face — refused by its `defineX` before any registry
23
+ * sees it, so a typo in a threshold or a version costs a boot and not a run.
24
+ */
25
+ export declare class InvalidDefinition extends Error {
26
+ readonly kind: string;
27
+ readonly definitionName: string;
28
+ constructor(kind: string, definitionName: string, problem: string);
29
+ }
30
+ export interface Registry<T> {
31
+ /** The registered thing, back, so a registration can be an expression. */
32
+ register(entry: T): T;
33
+ get(name: string): T | undefined;
34
+ /** `get` for callers with nothing sensible to do about an absence. */
35
+ require(name: string): T;
36
+ has(name: string): boolean;
37
+ names(): string[];
38
+ all(): T[];
39
+ readonly kind: string;
40
+ readonly size: number;
41
+ }
42
+ /**
43
+ * `keyOf` exists because not every registration calls its name `name`: a record type is keyed
44
+ * by `recordType`, which is the string machinery rows actually carry.
45
+ */
46
+ export declare function createRegistry<T>(kind: string, keyOf?: (entry: T) => string): Registry<T>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Every kind of thing an app registers — sources, resolvers, scorers, flows, approval types,
3
+ * channels, record types — is a registry, not a single fixed implementation, and every one of
4
+ * them refuses a second registration under a name it already holds.
5
+ *
6
+ * A duplicate is always a bug and never a harmless overwrite: the name is what a stored row
7
+ * points at, so silently replacing a registration would change what every `hf_run.flow`,
8
+ * `hf_approval.type` and `hf_action_log.channel` row already written means.
9
+ */
10
+ export class DuplicateRegistration extends Error {
11
+ kind;
12
+ /** Not `name`: that one is `Error`'s, and carries the class name on every error here. */
13
+ registeredName;
14
+ constructor(kind, registeredName) {
15
+ super(`DuplicateRegistration: a ${kind} named ${JSON.stringify(registeredName)} is already registered`);
16
+ this.name = "DuplicateRegistration";
17
+ this.kind = kind;
18
+ this.registeredName = registeredName;
19
+ }
20
+ }
21
+ export class UnknownRegistration extends Error {
22
+ kind;
23
+ known;
24
+ constructor(kind, name, known) {
25
+ super(`UnknownRegistration: no ${kind} named ${JSON.stringify(name)} is registered; ` +
26
+ `this app registers ${known.length === 0 ? "none" : known.join(", ")}`);
27
+ this.name = "UnknownRegistration";
28
+ this.kind = kind;
29
+ this.known = known;
30
+ }
31
+ }
32
+ /**
33
+ * A definition that is malformed on its face — refused by its `defineX` before any registry
34
+ * sees it, so a typo in a threshold or a version costs a boot and not a run.
35
+ */
36
+ export class InvalidDefinition extends Error {
37
+ kind;
38
+ definitionName;
39
+ constructor(kind, definitionName, problem) {
40
+ super(`InvalidDefinition: the ${kind} named ${JSON.stringify(definitionName)} ${problem}`);
41
+ this.name = "InvalidDefinition";
42
+ this.kind = kind;
43
+ this.definitionName = definitionName;
44
+ }
45
+ }
46
+ /**
47
+ * `keyOf` exists because not every registration calls its name `name`: a record type is keyed
48
+ * by `recordType`, which is the string machinery rows actually carry.
49
+ */
50
+ export function createRegistry(kind, keyOf = (entry) => entry.name) {
51
+ const entries = new Map();
52
+ return {
53
+ kind,
54
+ get size() {
55
+ return entries.size;
56
+ },
57
+ register(entry) {
58
+ const key = keyOf(entry);
59
+ if (entries.has(key))
60
+ throw new DuplicateRegistration(kind, key);
61
+ entries.set(key, entry);
62
+ return entry;
63
+ },
64
+ get: (name) => entries.get(name),
65
+ require(name) {
66
+ const entry = entries.get(name);
67
+ if (entry === undefined)
68
+ throw new UnknownRegistration(kind, name, [...entries.keys()]);
69
+ return entry;
70
+ },
71
+ has: (name) => entries.has(name),
72
+ names: () => [...entries.keys()],
73
+ all: () => [...entries.values()],
74
+ };
75
+ }