@firedrill-tools/unified 0.1.1

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 (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +242 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounds.scenario.json +23 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/large-channels.scenario.json +4251 -0
  8. package/firedrill/many-workspaces.scenario.json +1623 -0
  9. package/firedrill/rate-limited.scenario.json +11 -0
  10. package/firedrill/tools/unified/behavior.mjs +69 -0
  11. package/firedrill/tools/unified/lib/assoc.mjs +61 -0
  12. package/firedrill/tools/unified/lib/enums.mjs +14 -0
  13. package/firedrill/tools/unified/lib/errors.mjs +50 -0
  14. package/firedrill/tools/unified/lib/events.mjs +27 -0
  15. package/firedrill/tools/unified/lib/identity.mjs +59 -0
  16. package/firedrill/tools/unified/lib/query.mjs +154 -0
  17. package/firedrill/tools/unified/lib/shapes.mjs +176 -0
  18. package/firedrill/tools/unified/lib/store.mjs +75 -0
  19. package/firedrill/tools/unified/lib/time.mjs +59 -0
  20. package/firedrill/tools/unified/lib/util.mjs +92 -0
  21. package/firedrill/tools/unified/lib/validate.mjs +114 -0
  22. package/firedrill/tools/unified/lib/wire.mjs +120 -0
  23. package/firedrill/tools/unified/ops/channels.mjs +45 -0
  24. package/firedrill/tools/unified/ops/connections.mjs +151 -0
  25. package/firedrill/tools/unified/ops/crm.mjs +126 -0
  26. package/firedrill/tools/unified/ops/deals.mjs +123 -0
  27. package/firedrill/tools/unified/ops/messages.mjs +133 -0
  28. package/firedrill/tools/unified/ops/pipelines.mjs +20 -0
  29. package/firedrill/tools/unified/ops/resource.mjs +114 -0
  30. package/firedrill/tools/unified/unified.tool.json +14068 -0
  31. package/firedrill/unified-bounds.drill.json +148 -0
  32. package/firedrill/unified-connections-flow.drill.json +226 -0
  33. package/firedrill/unified-crm-flow.drill.json +576 -0
  34. package/firedrill/unified-denied.drill.json +83 -0
  35. package/firedrill/unified-error-coverage.drill.json +528 -0
  36. package/firedrill/unified-fresh-actor.drill.json +83 -0
  37. package/firedrill/unified-invalid-workspace.drill.json +933 -0
  38. package/firedrill/unified-large-channels.drill.json +68 -0
  39. package/firedrill/unified-many-workspaces.drill.json +943 -0
  40. package/firedrill/unified-mcp-core-aliases.drill.json +113 -0
  41. package/firedrill/unified-messaging-flow.drill.json +326 -0
  42. package/firedrill/unified-rate-limited.drill.json +933 -0
  43. package/firedrill/unified-scoped-token.drill.json +113 -0
  44. package/firedrill/unified-size-bounds.drill.json +263 -0
  45. package/firedrill/unified-write-committed-lost.drill.json +107 -0
  46. package/firedrill/unified-write-unavailable.drill.json +528 -0
  47. package/firedrill/world.json +2958 -0
  48. package/firedrill/write-committed-lost.scenario.json +11 -0
  49. package/firedrill/write-unavailable.scenario.json +11 -0
  50. package/firedrill.json +5 -0
  51. package/package.json +52 -0
  52. package/starter.json +2446 -0
  53. package/test/conformance.mjs +39 -0
  54. package/test/flows/connections.mjs +87 -0
  55. package/test/flows/coverage.mjs +64 -0
  56. package/test/flows/crm.mjs +124 -0
  57. package/test/flows/faults.mjs +82 -0
  58. package/test/flows/identity.mjs +84 -0
  59. package/test/flows/messaging.mjs +82 -0
  60. package/test/flows/size.mjs +71 -0
  61. package/test/lib.mjs +133 -0
@@ -0,0 +1,126 @@
1
+ // CRM contacts and companies: body specs, defaults, derived `name`, list filters.
2
+ import { badRequest } from "../lib/errors.mjs";
3
+ import { idFilter, textFilter } from "../lib/query.mjs";
4
+ import { bool, idArray, num, shape, str, strArray } from "../lib/validate.mjs";
5
+ import { makeResource } from "./resource.mjs";
6
+
7
+ const TEXT = 1000;
8
+
9
+ const CONTACT_SPECS = {
10
+ name: str(TEXT),
11
+ first_name: str(TEXT),
12
+ last_name: str(TEXT),
13
+ title: str(TEXT),
14
+ company: str(TEXT),
15
+ department: str(TEXT),
16
+ image_url: str(2048),
17
+ user_id: str(256),
18
+ emails: shape("emails"),
19
+ telephones: shape("telephones"),
20
+ address: shape("address"),
21
+ company_ids: idArray(100),
22
+ deal_ids: idArray(100),
23
+ link_urls: strArray(32, 2048),
24
+ metadata: shape("metadata"),
25
+ raw: shape("raw"),
26
+ };
27
+
28
+ const COMPANY_SPECS = {
29
+ name: str(TEXT, { nullable: false, min: 1 }),
30
+ description: str(20000),
31
+ industry: str(TEXT),
32
+ timezone: str(128),
33
+ employees: num({ min: 0, integer: true }),
34
+ is_active: bool(),
35
+ tags: strArray(100, 256),
36
+ websites: strArray(32, 253),
37
+ domains: strArray(32, 253),
38
+ emails: shape("emails"),
39
+ telephones: shape("telephones"),
40
+ address: shape("address"),
41
+ link_urls: strArray(32, 2048),
42
+ contact_ids: idArray(100),
43
+ deal_ids: idArray(100),
44
+ user_id: str(256),
45
+ metadata: shape("metadata"),
46
+ raw: shape("raw"),
47
+ };
48
+
49
+ const contactDefaults = () => ({
50
+ name: null, first_name: null, last_name: null, title: null, company: null, department: null, image_url: null, user_id: null,
51
+ emails: [], telephones: [], address: null, company_ids: [], deal_ids: [], link_urls: [], metadata: [], raw: {},
52
+ });
53
+
54
+ const companyDefaults = () => ({
55
+ name: "", description: null, industry: null, timezone: null, employees: null, is_active: true, tags: [], websites: [], domains: [],
56
+ emails: [], telephones: [], address: null, link_urls: [], contact_ids: [], deal_ids: [], user_id: null, metadata: [], raw: {},
57
+ });
58
+
59
+ /** `name` derives from first/last name when the body does not set it. */
60
+ function deriveName(fields) {
61
+ if (fields.name !== null && fields.name !== undefined) return fields.name;
62
+ const parts = [fields.first_name, fields.last_name].filter((part) => typeof part === "string" && part.length > 0);
63
+ return parts.length > 0 ? parts.join(" ") : null;
64
+ }
65
+
66
+ /** Reference filters by association array membership plus the exact `user_id`. */
67
+ function crmFilter(names) {
68
+ return (input, context) => {
69
+ const wanted = [];
70
+ for (const [param, field] of Object.entries(names)) {
71
+ const value = idFilter(input, context, param);
72
+ if (value !== null) wanted.push([field, value]);
73
+ }
74
+ const userId = textFilter(input, context, "user_id");
75
+ return (row) => {
76
+ if (userId !== null && row.user_id !== userId) return false;
77
+ for (const [field, value] of wanted) if (!Array.isArray(row[field]) || !row[field].includes(value)) return false;
78
+ return true;
79
+ };
80
+ };
81
+ }
82
+
83
+ export const contacts = makeResource({
84
+ namespace: "contacts",
85
+ label: "Contact",
86
+ category: "crm",
87
+ permission: "crm_contact",
88
+ objectType: "crm_contact",
89
+ searchFields: ["name", "first_name", "last_name", "emails"],
90
+ bodySpecs: CONTACT_SPECS,
91
+ defaults: contactDefaults,
92
+ filter: crmFilter({ company_id: "company_ids", deal_id: "deal_ids" }),
93
+ prepareCreate(context, connection, fields) {
94
+ const row = { ...contactDefaults(), ...fields };
95
+ row.name = deriveName(row);
96
+ if (row.name === null && row.emails.length === 0) badRequest(context, "At least one of name, first_name, last_name or emails is required");
97
+ return row;
98
+ },
99
+ prepareUpdate(context, connection, existing, fields) {
100
+ const merged = { ...existing, ...fields };
101
+ if (!Object.hasOwn(fields, "name") && (Object.hasOwn(fields, "first_name") || Object.hasOwn(fields, "last_name"))) {
102
+ merged.name = deriveName({ ...merged, name: null }) ?? merged.name;
103
+ }
104
+ if (merged.name === null && merged.emails.length === 0) badRequest(context, "At least one of name, first_name, last_name or emails is required");
105
+ return merged;
106
+ },
107
+ });
108
+
109
+ export const companies = makeResource({
110
+ namespace: "companies",
111
+ label: "Company",
112
+ category: "crm",
113
+ permission: "crm_company",
114
+ objectType: "crm_company",
115
+ searchFields: ["name", "emails", "domains", "websites"],
116
+ bodySpecs: COMPANY_SPECS,
117
+ defaults: companyDefaults,
118
+ filter: crmFilter({ contact_id: "contact_ids", deal_id: "deal_ids" }),
119
+ prepareCreate(context, connection, fields) {
120
+ if (typeof fields.name !== "string" || fields.name.length === 0) badRequest(context, "name is required");
121
+ return { ...companyDefaults(), ...fields };
122
+ },
123
+ prepareUpdate(context, connection, existing, fields) {
124
+ return { ...existing, ...fields };
125
+ },
126
+ });
@@ -0,0 +1,123 @@
1
+ // CRM deals: pipeline/stage validation against the connection's pipelines, probability defaulting and closed_at
2
+ // derivation when a deal enters or leaves a closed stage.
3
+ import { badRequest } from "../lib/errors.mjs";
4
+ import { idFilter, textFilter } from "../lib/query.mjs";
5
+ import { getRow, scanBound, scanConnection } from "../lib/store.mjs";
6
+ import { nowIso } from "../lib/time.mjs";
7
+ import { idArray, iso, num, shape, str, strArray } from "../lib/validate.mjs";
8
+ import { clip } from "../lib/util.mjs";
9
+ import { makeResource } from "./resource.mjs";
10
+
11
+ const DEAL_SPECS = {
12
+ name: str(1000, { nullable: false, min: 1 }),
13
+ description: str(20000),
14
+ source: str(256),
15
+ currency: str(3, { pattern: /^[A-Z]{3}$/ }),
16
+ amount: num({ min: 0 }),
17
+ probability: num({ min: 0, max: 100 }),
18
+ closed_at: iso(),
19
+ closing_at: iso(),
20
+ lost_reason: str(1000),
21
+ won_reason: str(1000),
22
+ tags: strArray(100, 256),
23
+ pipelines: shape("pipelineRefs"),
24
+ stages: shape("pipelineRefs"),
25
+ contact_ids: idArray(100),
26
+ company_ids: idArray(100),
27
+ user_id: str(256),
28
+ metadata: shape("metadata"),
29
+ raw: shape("raw"),
30
+ };
31
+
32
+ const defaults = () => ({
33
+ name: "", description: null, source: null, currency: null, amount: null, probability: null, closed_at: null, closing_at: null,
34
+ lost_reason: null, won_reason: null, tags: [], pipelines: [], stages: [], contact_ids: [], company_ids: [], user_id: null, metadata: [], raw: {},
35
+ });
36
+
37
+ const activeStages = (pipeline) => [...pipeline.stages].sort((a, b) => a.display_order - b.display_order);
38
+
39
+ /** Finds the pipeline owning a stage id across the connection (bounded scan of the small pipelines namespace). */
40
+ function pipelineOfStage(context, workspace, connectionId, stageId) {
41
+ for (const pipeline of scanConnection(context, "pipelines", connectionId, scanBound(workspace))) {
42
+ if (pipeline.stages.some((stage) => stage.id === stageId)) return pipeline;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Resolves `pipelines`/`stages` references: a stage without a pipeline implies its owner; a pipeline without a stage
49
+ * takes its first active stage; both must agree. Returns { pipelines, stages, stage } with names filled in.
50
+ */
51
+ function resolveStage(context, workspace, connectionId, pipelines, stages) {
52
+ if (pipelines.length === 0 && stages.length === 0) return { pipelines: [], stages: [], stage: null };
53
+ let pipeline = null;
54
+ if (pipelines.length > 0) {
55
+ pipeline = getRow(context, "pipelines", connectionId, pipelines[0].id);
56
+ if (pipeline === null) badRequest(context, `Unknown pipeline id ${clip(pipelines[0].id, 40)}`);
57
+ } else {
58
+ pipeline = pipelineOfStage(context, workspace, connectionId, stages[0].id);
59
+ if (pipeline === null) badRequest(context, `Unknown stage id ${clip(stages[0].id, 40)}`);
60
+ }
61
+ let stage = null;
62
+ if (stages.length > 0) {
63
+ stage = pipeline.stages.find((entry) => entry.id === stages[0].id) ?? null;
64
+ if (stage === null) badRequest(context, `Stage ${clip(stages[0].id, 40)} does not belong to pipeline ${pipeline.id}`);
65
+ } else {
66
+ stage = activeStages(pipeline).find((entry) => entry.active) ?? activeStages(pipeline)[0] ?? null;
67
+ }
68
+ return {
69
+ pipelines: [{ id: pipeline.id, name: pipeline.name, type: null }],
70
+ stages: stage === null ? [] : [{ id: stage.id, name: stage.name, type: null }],
71
+ stage,
72
+ };
73
+ }
74
+
75
+ function applyStage(context, row, resolved, previousStageId, bodyHasProbability) {
76
+ row.pipelines = resolved.pipelines;
77
+ row.stages = resolved.stages;
78
+ const stage = resolved.stage;
79
+ if (!bodyHasProbability && stage !== null) row.probability = stage.deal_probability;
80
+ const stageId = stage === null ? null : stage.id;
81
+ if (stageId !== previousStageId) {
82
+ if (stage !== null && stage.is_closed) row.closed_at = row.closed_at ?? nowIso(context);
83
+ else if (previousStageId !== null) row.closed_at = null;
84
+ }
85
+ }
86
+
87
+ export const deals = makeResource({
88
+ namespace: "deals",
89
+ label: "Deal",
90
+ category: "crm",
91
+ permission: "crm_deal",
92
+ objectType: "crm_deal",
93
+ searchFields: ["name"],
94
+ bodySpecs: DEAL_SPECS,
95
+ defaults,
96
+ filter(input, context) {
97
+ const companyId = idFilter(input, context, "company_id");
98
+ const contactId = idFilter(input, context, "contact_id");
99
+ const pipelineId = idFilter(input, context, "pipeline_id");
100
+ const userId = textFilter(input, context, "user_id");
101
+ return (row) =>
102
+ (companyId === null || row.company_ids.includes(companyId)) &&
103
+ (contactId === null || row.contact_ids.includes(contactId)) &&
104
+ (pipelineId === null || row.pipelines.some((entry) => entry.id === pipelineId)) &&
105
+ (userId === null || row.user_id === userId);
106
+ },
107
+ prepareCreate(context, connection, fields, workspace) {
108
+ if (typeof fields.name !== "string" || fields.name.length === 0) badRequest(context, "name is required");
109
+ const row = { ...defaults(), ...fields };
110
+ const resolved = resolveStage(context, workspace, connection.id, row.pipelines, row.stages);
111
+ applyStage(context, row, resolved, null, Object.hasOwn(fields, "probability") && fields.probability !== null);
112
+ return row;
113
+ },
114
+ prepareUpdate(context, connection, existing, fields, workspace) {
115
+ const merged = { ...existing, ...fields };
116
+ if (Object.hasOwn(fields, "pipelines") || Object.hasOwn(fields, "stages")) {
117
+ const previousStageId = existing.stages[0]?.id ?? null;
118
+ const resolved = resolveStage(context, workspace, connection.id, merged.pipelines, merged.stages);
119
+ applyStage(context, merged, resolved, previousStageId, Object.hasOwn(fields, "probability") && fields.probability !== null);
120
+ }
121
+ return merged;
122
+ },
123
+ });
@@ -0,0 +1,133 @@
1
+ // Messaging messages: list filters (channel aliases, threads, unread, date window, author, mentions), create (post or
2
+ // threaded reply with the author taken from the connection's authorised identity), update of text fields and
3
+ // is_unread, remove with the parent's has_children recomputed.
4
+ import { badRequest } from "../lib/errors.mjs";
5
+ import { enumFilter, idFilter, textFilter } from "../lib/query.mjs";
6
+ import { getRow, putRow, scanBound, scanConnection } from "../lib/store.mjs";
7
+ import { parseIsoUs } from "../lib/time.mjs";
8
+ import { bool, shape, str } from "../lib/validate.mjs";
9
+ import { clip, isHex24 } from "../lib/util.mjs";
10
+ import { makeResource, rowsOf } from "./resource.mjs";
11
+
12
+ const ALIASES = new Set(["INBOX", "SENT", "DRAFT"]);
13
+ const TEXT_FIELDS = ["message", "message_html", "message_markdown"];
14
+
15
+ const MESSAGE_SPECS = {
16
+ channels: shape("channelRefs"),
17
+ parent_id: str(24, { pattern: /^[0-9a-f]{24}$/ }),
18
+ message_thread_identifier: str(256),
19
+ author_member: shape("member"),
20
+ destination_members: shape("members"),
21
+ hidden_members: shape("members"),
22
+ mentioned_members: shape("members"),
23
+ reactions: shape("reactions"),
24
+ subject: str(1000),
25
+ message: str(20000),
26
+ message_html: str(20000),
27
+ message_markdown: str(20000),
28
+ attachments: shape("attachments"),
29
+ web_url: str(2048),
30
+ reference: str(256),
31
+ has_children: bool(),
32
+ is_unread: bool(),
33
+ buttons: shape("buttons"),
34
+ raw: shape("raw"),
35
+ };
36
+
37
+ const defaults = () => ({
38
+ channels: [], parent_id: null, message_thread_identifier: null, author_member: { user_id: null, email: null, name: null, image_url: null },
39
+ destination_members: [], hidden_members: [], mentioned_members: [], reactions: [], subject: null, message: null, message_html: null,
40
+ message_markdown: null, attachments: [], web_url: null, reference: null, has_children: false, is_unread: false, buttons: [], raw: {},
41
+ });
42
+
43
+ const hasText = (row) => TEXT_FIELDS.some((field) => typeof row[field] === "string" && row[field].trim().length > 0);
44
+
45
+ function dateBound(input, context, name) {
46
+ const value = input[name];
47
+ if (value === undefined || value === null) return null;
48
+ const us = typeof value === "string" ? parseIsoUs(value) : null;
49
+ if (us === null) badRequest(context, `${name} must be an ISO-8601 date or date-time`);
50
+ return us;
51
+ }
52
+
53
+ /** Resolves INBOX/SENT/DRAFT to the connection's channel carrying that alias; a connection without one matches nothing. */
54
+ function resolveChannel(context, workspace, connectionId, value) {
55
+ if (!ALIASES.has(value)) return value;
56
+ const channel = scanConnection(context, "channels", connectionId, scanBound(workspace)).find((row) => row.alias === value);
57
+ return channel === undefined ? "" : channel.id;
58
+ }
59
+
60
+ export const messages = makeResource({
61
+ namespace: "messages",
62
+ label: "Message",
63
+ category: "messaging",
64
+ permission: "messaging_message",
65
+ objectType: "messaging_message",
66
+ searchFields: ["subject", "message", "author_member"],
67
+ nameField: "subject",
68
+ bodySpecs: MESSAGE_SPECS,
69
+ defaults,
70
+ filter(input, context, connection, workspace) {
71
+ const channelParam = idFilter(input, context, "channel_id", ALIASES);
72
+ const channelId = channelParam === null ? null : resolveChannel(context, workspace, connection.id, channelParam);
73
+ const parentId = idFilter(input, context, "parent_id");
74
+ const type = enumFilter(input, context, "type", ["READ", "UNREAD"]);
75
+ const startUs = dateBound(input, context, "start_gte");
76
+ const endUs = dateBound(input, context, "end_lt");
77
+ const userId = textFilter(input, context, "user_id");
78
+ const mentioned = textFilter(input, context, "user_mentioned_id");
79
+ if (input.expand !== undefined && input.expand !== null && typeof input.expand !== "string") badRequest(context, "expand must be a string");
80
+ return (row) => {
81
+ if (channelId !== null && !row.channels.some((channel) => channel.id === channelId)) return false;
82
+ if (parentId !== null && row.parent_id !== parentId) return false;
83
+ if (type !== null && row.is_unread !== (type === "UNREAD")) return false;
84
+ const createdUs = parseIsoUs(row.created_at) ?? 0;
85
+ if (startUs !== null && createdUs < startUs) return false;
86
+ if (endUs !== null && createdUs >= endUs) return false;
87
+ if (userId !== null && row.author_member?.user_id !== userId) return false;
88
+ if (mentioned !== null && !row.mentioned_members.some((member) => member.user_id === mentioned)) return false;
89
+ return true;
90
+ };
91
+ },
92
+ prepareCreate(context, connection, fields) {
93
+ const row = { ...defaults(), ...fields };
94
+ if (!hasText(row)) badRequest(context, "One of message, message_html or message_markdown is required");
95
+ if (row.parent_id !== null) {
96
+ const parent = getRow(context, "messages", connection.id, row.parent_id);
97
+ if (parent === null) badRequest(context, `Unknown parent_id ${clip(row.parent_id, 40)}`);
98
+ if (row.channels.length === 0) row.channels = parent.channels.map((channel) => ({ ...channel }));
99
+ }
100
+ if (row.channels.length === 0) badRequest(context, "channels[0].id or parent_id is required");
101
+ row.channels = row.channels.map((ref) => {
102
+ const channel = getRow(context, "channels", connection.id, ref.id);
103
+ if (channel === null) badRequest(context, `Unknown channel id ${clip(ref.id, 40)}`);
104
+ return { id: channel.id, name: channel.name };
105
+ });
106
+ const auth = connection.auth ?? {};
107
+ row.author_member = { user_id: auth.user_id ?? null, email: Array.isArray(auth.emails) && auth.emails.length > 0 ? auth.emails[0] : null, name: auth.name ?? null, image_url: null };
108
+ row.has_children = false;
109
+ row.reactions = [];
110
+ return row;
111
+ },
112
+ afterCreate(context, connection, row) {
113
+ if (row.parent_id === null) return;
114
+ const parent = getRow(context, "messages", connection.id, row.parent_id);
115
+ if (parent !== null && parent.has_children !== true) putRow(context, "messages", { ...parent, has_children: true });
116
+ },
117
+ prepareUpdate(context, connection, existing, fields) {
118
+ const merged = { ...existing };
119
+ for (const field of [...TEXT_FIELDS, "subject", "is_unread"]) {
120
+ if (Object.hasOwn(fields, field) && fields[field] !== null) merged[field] = fields[field];
121
+ else if (Object.hasOwn(fields, field) && field !== "is_unread") merged[field] = null;
122
+ }
123
+ if (!hasText(merged)) badRequest(context, "One of message, message_html or message_markdown must remain set");
124
+ return merged;
125
+ },
126
+ beforeRemove(context, connection, existing, workspace) {
127
+ if (existing.parent_id === null || !isHex24(existing.parent_id)) return;
128
+ const parent = getRow(context, "messages", connection.id, existing.parent_id);
129
+ if (parent === null) return;
130
+ const siblings = rowsOf(context, workspace, { namespace: "messages" }, connection.id).filter((row) => row.parent_id === parent.id && row.id !== existing.id);
131
+ if (parent.has_children !== siblings.length > 0) putRow(context, "messages", { ...parent, has_children: siblings.length > 0 });
132
+ },
133
+ });
@@ -0,0 +1,20 @@
1
+ // CRM pipelines: read-only reference data (list and get); stages are returned ordered by display_order.
2
+ import { makeResource } from "./resource.mjs";
3
+
4
+ const orderStages = (item) =>
5
+ Array.isArray(item.stages) ? { ...item, stages: [...item.stages].sort((a, b) => a.display_order - b.display_order || (a.id < b.id ? -1 : 1)) } : item;
6
+
7
+ export const pipelines = makeResource({
8
+ namespace: "pipelines",
9
+ label: "Pipeline",
10
+ category: "crm",
11
+ permission: "crm_pipeline",
12
+ objectType: "crm_pipeline",
13
+ searchFields: ["name"],
14
+ bodySpecs: {},
15
+ defaults: () => ({}),
16
+ filter: () => () => true,
17
+ present: orderStages,
18
+ prepareCreate: () => ({}),
19
+ prepareUpdate: (context, connection, existing) => existing,
20
+ });
@@ -0,0 +1,114 @@
1
+ // Generic list/get/create/update/remove engine shared by every connection-scoped resource. A resource spec supplies
2
+ // the namespace, category, permission slug, object type, searchable fields, list filters, body specs and hooks;
3
+ // everything else (identity rules, paging, projection, events, bounds) is computed here from `context.state`.
4
+ import { CRM, checkReferences, detachAll, syncAssociations } from "../lib/assoc.mjs";
5
+ import { badRequest, notFound, scanBoundExceeded } from "../lib/errors.mjs";
6
+ import { emitCreated, emitDeleted, emitUpdated } from "../lib/events.mjs";
7
+ import { requireConnection, resolveWorkspace } from "../lib/identity.mjs";
8
+ import { finishPage, matchesQuery, parseFields, parseListParams, project, sortRows, updatedSince } from "../lib/query.mjs";
9
+ import { deleteRow, getRow, nextId, putRow, scanBound, scanConnection } from "../lib/store.mjs";
10
+ import { nowIso } from "../lib/time.mjs";
11
+ import { bodyOf, validateBody } from "../lib/validate.mjs";
12
+ import { isHex24, jsonEqual } from "../lib/util.mjs";
13
+
14
+ const ADDRESSING = ["connection_id", "id", "__request_error"];
15
+
16
+ export function open(input, context, spec, mode) {
17
+ const workspace = resolveWorkspace(input, context);
18
+ const connection = requireConnection(context, workspace, input.connection_id, spec.category, `${spec.permission}_${mode}`);
19
+ return { workspace, connection };
20
+ }
21
+
22
+ export function requireId(context, spec, connectionId, id) {
23
+ if (!isHex24(id)) badRequest(context, `Invalid ${spec.label.toLowerCase()} id`);
24
+ const row = getRow(context, spec.namespace, connectionId, id);
25
+ if (row === null) notFound(context, `${spec.label} not found`);
26
+ return row;
27
+ }
28
+
29
+ /** Rows of one connection, in creation order, after the bounded scan. */
30
+ export function rowsOf(context, workspace, spec, connectionId) {
31
+ return scanConnection(context, spec.namespace, connectionId, scanBound(workspace));
32
+ }
33
+
34
+ /** Top-level fields whose stored value differs between two rows. */
35
+ export function changedFields(before, after) {
36
+ const out = [];
37
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
38
+ if (key === "updated_at") continue;
39
+ if (!jsonEqual(before[key], after[key])) out.push(key);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export function makeResource(spec) {
45
+ const hidden = spec.hidden ?? [];
46
+ const isCrm = Object.hasOwn(CRM, spec.namespace);
47
+
48
+ const list = (input, context) => {
49
+ const { workspace, connection } = open(input, context, spec, "read");
50
+ const params = parseListParams(input, context);
51
+ const fields = parseFields(input, context);
52
+ const filter = spec.filter(input, context, connection, workspace);
53
+ const rows = rowsOf(context, workspace, spec, connection.id).filter(
54
+ (row) => updatedSince(row, params.updatedGteUs) && matchesQuery(row, params.query, spec.searchFields) && filter(row),
55
+ );
56
+ sortRows(rows, params.sort, params.order, spec.nameField ?? "name");
57
+ const page = finishPage(context, rows, params, fields, hidden);
58
+ return spec.present ? page.map((item) => spec.present(item, context, workspace)) : page;
59
+ };
60
+
61
+ const get = (input, context) => {
62
+ const { workspace, connection } = open(input, context, spec, "read");
63
+ const fields = parseFields(input, context);
64
+ const row = requireId(context, spec, connection.id, input.id);
65
+ const item = project(row, fields, hidden);
66
+ return spec.present ? spec.present(item, context, workspace) : item;
67
+ };
68
+
69
+ const create = (input, context) => {
70
+ const { workspace, connection } = open(input, context, spec, "write");
71
+ const fields = validateBody(context, bodyOf(input, ADDRESSING), spec.bodySpecs);
72
+ const prepared = spec.prepareCreate(context, connection, fields, workspace);
73
+ if (isCrm) checkReferences(context, connection.id, spec.namespace, prepared);
74
+ // The namespace must stay within the per-connection bound after this write.
75
+ const bound = scanBound(workspace);
76
+ const existing = rowsOf(context, workspace, spec, connection.id);
77
+ if (existing.length >= bound) scanBoundExceeded(context, spec.namespace, bound);
78
+ const now = nowIso(context);
79
+ const id = nextId(context);
80
+ const row = { ...spec.defaults(), ...prepared, connection_id: connection.id, id, created_at: now, updated_at: now };
81
+ putRow(context, spec.namespace, row);
82
+ if (isCrm) syncAssociations(context, connection, spec.namespace, row, null);
83
+ if (spec.afterCreate) spec.afterCreate(context, connection, row);
84
+ emitCreated(context, connection, spec.objectType, id);
85
+ return project(row, null, hidden);
86
+ };
87
+
88
+ const update = (input, context) => {
89
+ const { workspace, connection } = open(input, context, spec, "write");
90
+ const existing = requireId(context, spec, connection.id, input.id);
91
+ const fields = validateBody(context, bodyOf(input, ADDRESSING), spec.updateSpecs ?? spec.bodySpecs);
92
+ const merged = spec.prepareUpdate(context, connection, existing, fields, workspace);
93
+ if (isCrm) checkReferences(context, connection.id, spec.namespace, merged);
94
+ const changed = changedFields(existing, merged);
95
+ if (changed.length === 0) return project(existing, null, hidden);
96
+ const row = { ...merged, connection_id: connection.id, id: existing.id, created_at: existing.created_at, updated_at: nowIso(context) };
97
+ putRow(context, spec.namespace, row);
98
+ if (isCrm) syncAssociations(context, connection, spec.namespace, row, existing);
99
+ emitUpdated(context, connection, spec.objectType, row.id, changed);
100
+ return project(row, null, hidden);
101
+ };
102
+
103
+ const remove = (input, context) => {
104
+ const { workspace, connection } = open(input, context, spec, "write");
105
+ const existing = requireId(context, spec, connection.id, input.id);
106
+ if (spec.beforeRemove) spec.beforeRemove(context, connection, existing, workspace);
107
+ if (isCrm) detachAll(context, connection, spec.namespace, existing);
108
+ deleteRow(context, spec.namespace, connection.id, existing.id);
109
+ emitDeleted(context, connection, spec.objectType, existing.id);
110
+ return {};
111
+ };
112
+
113
+ return { list, get, create, update, remove };
114
+ }