streamby-core 0.42.0 → 0.43.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 (39) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +92 -4
  3. package/dist/adapters/database/connectionManager.js +4 -0
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +5 -1
  6. package/dist/mcp/tools.js +25 -11
  7. package/dist/mcp/workflowNodes.js +9 -3
  8. package/dist/middleware/createRouter.js +2 -0
  9. package/dist/middleware/routes/connection.js +5 -2
  10. package/dist/middleware/routes/dbConnection.js +29 -5
  11. package/dist/middleware/routes/document.js +11 -6
  12. package/dist/middleware/routes/export.d.ts +28 -0
  13. package/dist/middleware/routes/export.js +8 -4
  14. package/dist/middleware/routes/member.js +24 -5
  15. package/dist/middleware/routes/pipeline.js +5 -2
  16. package/dist/middleware/routes/project.js +14 -12
  17. package/dist/middleware/routes/projectResources.d.ts +8 -0
  18. package/dist/middleware/routes/projectResources.js +51 -0
  19. package/dist/middleware/routes/rights.js +72 -52
  20. package/dist/middleware/routes/storage.js +35 -2
  21. package/dist/middleware/routes/storageConnection.js +38 -15
  22. package/dist/middleware/routes/userIntegration.js +42 -1
  23. package/dist/middleware/routes/workflow.js +120 -37
  24. package/dist/models/Model.js +13 -21
  25. package/dist/services/apiTokenPermissions.js +2 -0
  26. package/dist/services/creditsSheets.d.ts +39 -0
  27. package/dist/services/creditsSheets.js +129 -0
  28. package/dist/services/dependents.d.ts +11 -0
  29. package/dist/services/dependents.js +81 -0
  30. package/dist/services/production.d.ts +1 -1
  31. package/dist/services/production.js +2 -2
  32. package/dist/services/projectIntegration.d.ts +14 -0
  33. package/dist/services/projectIntegration.js +32 -0
  34. package/dist/services/rights.d.ts +44 -0
  35. package/dist/services/rights.js +123 -0
  36. package/dist/services/workflowResources.d.ts +90 -0
  37. package/dist/services/workflowResources.js +288 -0
  38. package/dist/types/index.d.ts +6 -1
  39. package/package.json +1 -1
@@ -13,4 +13,18 @@ type ClassifyResult = {
13
13
  status: number;
14
14
  };
15
15
  export declare function classifyIntegrationId(id: string, auth: Auth, config: StreamByConfig, projectId: string): Promise<ClassifyResult>;
16
+ declare const LIST_OF: {
17
+ readonly database: "dbConnections";
18
+ readonly storage: "storageConnections";
19
+ readonly service: "integrationConnections";
20
+ };
21
+ /**
22
+ * Connects an integration to a project: a built-in (the host's database or storage) or one of
23
+ * the user's own. A built-in it was disconnected from comes back with the id it had, so what
24
+ * was kept for it (its files, its records) is its own again. With `kind`, only one of that kind.
25
+ */
26
+ export declare function connectIntegration(project: any, integrationId: string, auth: Auth, config: StreamByConfig, options?: {
27
+ kind?: keyof typeof LIST_OF;
28
+ description?: string;
29
+ }): Promise<ClassifyResult>;
16
30
  export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.classifyIntegrationId = classifyIntegrationId;
7
+ exports.connectIntegration = connectIntegration;
7
8
  const crypto_1 = __importDefault(require("crypto"));
8
9
  const manager_1 = require("../models/manager");
9
10
  const builtinAccess_1 = require("../utils/builtinAccess");
@@ -71,3 +72,34 @@ async function classifyIntegrationId(id, auth, config, projectId) {
71
72
  },
72
73
  };
73
74
  }
75
+ const LIST_OF = { database: 'dbConnections', storage: 'storageConnections', service: 'integrationConnections' };
76
+ const NOUN_OF = { database: 'a database', storage: 'a storage', service: 'a service' };
77
+ /**
78
+ * Connects an integration to a project: a built-in (the host's database or storage) or one of
79
+ * the user's own. A built-in it was disconnected from comes back with the id it had, so what
80
+ * was kept for it (its files, its records) is its own again. With `kind`, only one of that kind.
81
+ */
82
+ async function connectIntegration(project, integrationId, auth, config, options = {}) {
83
+ const projectId = String(project._id ?? project.id);
84
+ const alreadyConnected = [...(project.dbConnections ?? []), ...(project.storageConnections ?? []), ...(project.integrationConnections ?? [])]
85
+ .some((c) => c.integrationId === integrationId);
86
+ if (alreadyConnected)
87
+ return { error: 'This integration is already connected to the project', status: 400 };
88
+ const result = await classifyIntegrationId(integrationId, auth, config, projectId);
89
+ if ('error' in result)
90
+ return result;
91
+ if (options.kind && result.kind !== options.kind)
92
+ return { error: `This integration isn't ${NOUN_OF[options.kind]}`, status: 400 };
93
+ const before = (project.disconnectedBuiltins ?? []).find((d) => d.integrationId === integrationId);
94
+ const connection = {
95
+ ...result.connection,
96
+ ...(before && result.connection.source === 'builtin' ? { id: before.connectionId } : {}),
97
+ ...(options.description ? { description: options.description } : {}),
98
+ };
99
+ const Project = (0, manager_1.getModel)('projects');
100
+ await Project.update({ _id: projectId }, { $push: { [LIST_OF[result.kind]]: connection } });
101
+ // By integrationId alone: a SQL project's $pull matches one key.
102
+ if (before)
103
+ await Project.update({ _id: projectId }, { $pull: { disconnectedBuiltins: { integrationId } } });
104
+ return { kind: result.kind, connection };
105
+ }
@@ -0,0 +1,44 @@
1
+ export declare const LICENSE_TYPES: readonly ["CC BY", "CC BY-SA", "CC BY-ND", "CC BY-NC", "CC BY-NC-SA", "CC BY-NC-ND", "CC0", "Free Standard", "Standard", "Editorial"];
2
+ export type LicenseType = typeof LICENSE_TYPES[number];
3
+ export declare const RIGHTS_FIELDS: readonly ["sourceUrl", "creatorName", "creatorUrl", "licenseType", "comments"];
4
+ type RightsField = typeof RIGHTS_FIELDS[number];
5
+ export interface AssetRights {
6
+ assetId: string;
7
+ projectId: string;
8
+ sourceUrl: string | null;
9
+ creatorName: string | null;
10
+ creatorUrl: string | null;
11
+ licenseType: LicenseType | null;
12
+ comments: string | null;
13
+ updatedBy?: string;
14
+ updatedAt?: Date;
15
+ }
16
+ export type RightsValues = Partial<Pick<AssetRights, RightsField>>;
17
+ /**
18
+ * The fields given of an asset's license metadata, checked: text (a link, an http(s) URL) and a
19
+ * license from the list. A field given empty or null is cleared.
20
+ */
21
+ export declare function checkRights(body: any): {
22
+ values: RightsValues;
23
+ } | {
24
+ error: string;
25
+ };
26
+ /** The license metadata given as query params (an upload's), checked; null if none given. */
27
+ export declare function rightsFromQuery(query: Record<string, unknown>): {
28
+ values: RightsValues;
29
+ } | {
30
+ error: string;
31
+ } | null;
32
+ export declare function getRights(projectId: string, assetId: string): Promise<AssetRights | null>;
33
+ /** Several assets' license metadata, by asset id (those with none are missing). */
34
+ export declare function getRightsOf(projectId: string, assetIds: string[]): Promise<Map<string, AssetRights>>;
35
+ export declare function broadcastRights(project: any, data: {
36
+ action: 'saved' | 'deleted';
37
+ assetIds: string[];
38
+ rights?: AssetRights;
39
+ }): void;
40
+ /** Sets the fields given of an asset's license metadata (checked already), and tells the project. */
41
+ export declare function saveRights(project: any, assetId: string, values: RightsValues, userId: string): Promise<AssetRights>;
42
+ /** An asset's license metadata, gone with it (or, without an asset, a whole project's). */
43
+ export declare function deleteRightsOf(projectId: string, assetId?: string): Promise<void>;
44
+ export {};
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RIGHTS_FIELDS = exports.LICENSE_TYPES = void 0;
4
+ exports.checkRights = checkRights;
5
+ exports.rightsFromQuery = rightsFromQuery;
6
+ exports.getRights = getRights;
7
+ exports.getRightsOf = getRightsOf;
8
+ exports.broadcastRights = broadcastRights;
9
+ exports.saveRights = saveRights;
10
+ exports.deleteRightsOf = deleteRightsOf;
11
+ const nosqlDb_1 = require("../utils/nosqlDb");
12
+ const auth_1 = require("../utils/auth");
13
+ const wsHub_1 = require("./wsHub");
14
+ // An asset's license metadata: where it was taken from, who made it, and under what license.
15
+ // Kept in the `asset_rights` collection, one record per asset (for all its versions), the
16
+ // only place it lives: a credits sheet reads it from here, and writes to here.
17
+ exports.LICENSE_TYPES = [
18
+ 'CC BY', 'CC BY-SA', 'CC BY-ND', 'CC BY-NC', 'CC BY-NC-SA', 'CC BY-NC-ND', 'CC0', 'Free Standard', 'Standard', 'Editorial',
19
+ ];
20
+ exports.RIGHTS_FIELDS = ['sourceUrl', 'creatorName', 'creatorUrl', 'licenseType', 'comments'];
21
+ const URL_FIELDS = ['sourceUrl', 'creatorUrl'];
22
+ const MAX_TEXT = 2000;
23
+ const isLicense = (value) => exports.LICENSE_TYPES.includes(value);
24
+ const isUrl = (value) => {
25
+ try {
26
+ return ['http:', 'https:'].includes(new URL(value).protocol);
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ };
32
+ /**
33
+ * The fields given of an asset's license metadata, checked: text (a link, an http(s) URL) and a
34
+ * license from the list. A field given empty or null is cleared.
35
+ */
36
+ function checkRights(body) {
37
+ const values = {};
38
+ for (const field of exports.RIGHTS_FIELDS) {
39
+ const raw = body?.[field];
40
+ if (raw === undefined)
41
+ continue;
42
+ if (raw !== null && typeof raw !== 'string')
43
+ return { error: `${field} must be text` };
44
+ const value = typeof raw === 'string' ? raw.trim() : '';
45
+ if (!value) {
46
+ values[field] = null;
47
+ continue;
48
+ }
49
+ if (value.length > MAX_TEXT)
50
+ return { error: `${field} has at most ${MAX_TEXT} characters` };
51
+ if (field === 'licenseType') {
52
+ if (!isLicense(value))
53
+ return { error: `licenseType is one of: ${exports.LICENSE_TYPES.join(', ')}` };
54
+ values.licenseType = value;
55
+ }
56
+ else if (URL_FIELDS.includes(field) && !isUrl(value)) {
57
+ return { error: `${field} must be an http(s) URL` };
58
+ }
59
+ else {
60
+ values[field] = value;
61
+ }
62
+ }
63
+ return { values };
64
+ }
65
+ /** The license metadata given as query params (an upload's), checked; null if none given. */
66
+ function rightsFromQuery(query) {
67
+ const given = Object.fromEntries(exports.RIGHTS_FIELDS.filter(f => typeof query[f] === 'string').map(f => [f, query[f]]));
68
+ return Object.keys(given).length ? checkRights(given) : null;
69
+ }
70
+ const collection = () => (0, nosqlDb_1.nosqlDb)()?.collection('asset_rights') ?? null;
71
+ // A record as it's answered: only the fields it has now (a license off the list reads as none).
72
+ const readRights = (record) => record && {
73
+ assetId: record.assetId,
74
+ projectId: record.projectId,
75
+ sourceUrl: record.sourceUrl ?? null,
76
+ creatorName: record.creatorName ?? null,
77
+ creatorUrl: record.creatorUrl ?? null,
78
+ licenseType: isLicense(record.licenseType) ? record.licenseType : null,
79
+ comments: record.comments ?? null,
80
+ ...(record.updatedBy ? { updatedBy: record.updatedBy } : {}),
81
+ ...(record.updatedAt ? { updatedAt: record.updatedAt } : {}),
82
+ };
83
+ async function getRights(projectId, assetId) {
84
+ const col = collection();
85
+ return col ? readRights(await col.findOne({ projectId, assetId })) : null;
86
+ }
87
+ /** Several assets' license metadata, by asset id (those with none are missing). */
88
+ async function getRightsOf(projectId, assetIds) {
89
+ const col = collection();
90
+ if (!col || !assetIds.length)
91
+ return new Map();
92
+ const records = await col.find({ projectId, assetId: { $in: assetIds } }).toArray();
93
+ return new Map(records.map(r => [r.assetId, readRights(r)]));
94
+ }
95
+ // What changed in a project's assets' license metadata, to whoever is in it now: the credits
96
+ // sheets they have open read their rows again.
97
+ function broadcastRights(project, data) {
98
+ const projectId = String(project._id ?? project.id);
99
+ for (const member of project.members ?? []) {
100
+ if ((0, auth_1.activeMemberOf)(project, member.userId))
101
+ (0, wsHub_1.emitToUser)(member.userId, { type: 'assetRightsEvent', data: { projectId, ...data } });
102
+ }
103
+ }
104
+ /** Sets the fields given of an asset's license metadata (checked already), and tells the project. */
105
+ async function saveRights(project, assetId, values, userId) {
106
+ const col = collection();
107
+ if (!col)
108
+ throw new Error('Database not available');
109
+ const projectId = String(project._id ?? project.id);
110
+ // The fields an earlier record had that this one doesn't (holder, territory...) go.
111
+ const $unset = { rightsHolder: '', licenseUrl: '', territory: '', expiresAt: '', usageRestrictions: '', notes: '' };
112
+ await col.updateOne({ projectId, assetId }, { $set: { assetId, projectId, ...values, updatedBy: userId, updatedAt: new Date() }, $unset }, { upsert: true });
113
+ const rights = (await getRights(projectId, assetId));
114
+ broadcastRights(project, { action: 'saved', assetIds: [assetId], rights });
115
+ return rights;
116
+ }
117
+ /** An asset's license metadata, gone with it (or, without an asset, a whole project's). */
118
+ async function deleteRightsOf(projectId, assetId) {
119
+ const col = collection();
120
+ if (!col || !projectId)
121
+ return;
122
+ await col.deleteMany(assetId ? { projectId, assetId } : { projectId });
123
+ }
@@ -0,0 +1,90 @@
1
+ import { NodeSchema, StreamByConfig } from '../types';
2
+ export type ResourceKind = 'db' | 'api' | 'storage' | 'service' | 'export' | 'pipeline';
3
+ type Row = 'input' | 'export' | 'pipeline';
4
+ interface NodeKind {
5
+ type: string;
6
+ field: string;
7
+ list: string;
8
+ row: Row;
9
+ subtitle: (resource: any) => string;
10
+ }
11
+ export declare const NODE_OF: Record<ResourceKind, NodeKind>;
12
+ export declare const CONNECT_PREFIX = "__connect__:";
13
+ declare const pendingOf: (value: unknown) => string | null;
14
+ declare const emptySchema: () => NodeSchema;
15
+ declare const schemaOf: (workflow: any) => NodeSchema;
16
+ /** The resource a node is bound to, if it's one of a resource's nodes and bound already. */
17
+ export declare function resourceOfNode(node: any): {
18
+ kind: ResourceKind;
19
+ id: string;
20
+ } | null;
21
+ /** The resources the workflow's nodes are bound to, by kind. */
22
+ export declare function referencedIn(schema: NodeSchema): Record<ResourceKind, Set<string>>;
23
+ /**
24
+ * Whether a node consumes its resource rather than being it: it holds a configuration of its own
25
+ * beside the resource's id (a data source reading one table, an Ingest importing one file, a
26
+ * service node set to an action). A workflow may have many beside the resource's own node, and
27
+ * they're never merged into it.
28
+ */
29
+ export declare function consumes(node: any): boolean;
30
+ /** Whether a node is its resource's own node (bound to it, and not one consuming it). */
31
+ export declare const isOwnNode: (node: any, kind: ResourceKind, id: string) => boolean;
32
+ /**
33
+ * The schema with the resource's own node, if it has none yet (as the canvas wires one): nodes
34
+ * consuming it (an Ingest importing one of its files) aren't it, and removing them all mustn't
35
+ * remove it unasked.
36
+ */
37
+ export declare function withResourceNode(schema: NodeSchema, kind: ResourceKind, resource: any): NodeSchema;
38
+ /** The schema without the resource's nodes, nor their edges. */
39
+ export declare function withoutResourceNodes(schema: NodeSchema, kind: ResourceKind, id: string): NodeSchema;
40
+ /** One node per resource: a second node bound to the same one merges into the first (its edges go to it). */
41
+ export declare function oneNodePerResource(schema: NodeSchema): NodeSchema;
42
+ /**
43
+ * A workflow made before it defined the project, brought to it once: the nodes once drawn from
44
+ * the project's lists bound by the id in their data (an API's had it only in its node id), the
45
+ * orchestrator there, a node for each resource it has without one, and one node per resource.
46
+ */
47
+ export declare function migrateSchema(project: any, schema: NodeSchema): NodeSchema;
48
+ /** Saves the project's workflow (its single one, made if it has none) with the changes given. */
49
+ export declare function writeWorkflow(project: any, changes: Record<string, unknown>): Promise<any>;
50
+ /** The project's workflow as it's answered: brought once to define the project (see migrateSchema). */
51
+ export declare function readWorkflow(project: any): Promise<any>;
52
+ /** A resource added from its section (or by an agent): its node, in the workflow. */
53
+ export declare const addToWorkflow: (projectId: string, kind: ResourceKind, resource: any) => Promise<void>;
54
+ /** A resource removed from its section: its nodes, gone from the workflow. */
55
+ export declare const removeFromWorkflow: (projectId: string, kind: ResourceKind, id: string) => Promise<void>;
56
+ /** Rewrites ProjectInfo.pipelines from the pipelines collection, sorted by order. */
57
+ export declare function syncPipelineRefs(projectId: string): Promise<void>;
58
+ /**
59
+ * Removes a resource from the project, only it (its node is the caller's: the workflow saved
60
+ * without it, or removeFromWorkflow). A built-in disconnected keeps its id, to come back as it was.
61
+ */
62
+ export declare function removeResourceOf(project: any, kind: ResourceKind, id: string, config: StreamByConfig): Promise<void>;
63
+ /** A pipeline made in the project (its refs kept in sync), as its section makes one. */
64
+ export declare function createPipelineOf(projectId: string, name: string, userId: string): Promise<any>;
65
+ /** The project's resource lists as they're answered after a change. */
66
+ export declare function listsOf(projectId: string): Promise<{
67
+ dbConnections: (Omit<{
68
+ encryptedCredential?: string;
69
+ }, "encryptedCredential"> & {
70
+ hasCredential: boolean;
71
+ })[];
72
+ storageConnections: (Omit<{
73
+ encryptedCredential?: string;
74
+ }, "encryptedCredential"> & {
75
+ hasCredential: boolean;
76
+ })[];
77
+ integrationConnections: (Omit<{
78
+ encryptedCredential?: string;
79
+ }, "encryptedCredential"> & {
80
+ hasCredential: boolean;
81
+ })[];
82
+ connections: (Omit<{
83
+ encryptedCredential?: string;
84
+ }, "encryptedCredential"> & {
85
+ hasCredential: boolean;
86
+ })[];
87
+ exports: any[];
88
+ pipelines: any;
89
+ }>;
90
+ export { emptySchema, pendingOf, schemaOf };
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.schemaOf = exports.pendingOf = exports.emptySchema = exports.removeFromWorkflow = exports.addToWorkflow = exports.isOwnNode = exports.CONNECT_PREFIX = exports.NODE_OF = void 0;
7
+ exports.resourceOfNode = resourceOfNode;
8
+ exports.referencedIn = referencedIn;
9
+ exports.consumes = consumes;
10
+ exports.withResourceNode = withResourceNode;
11
+ exports.withoutResourceNodes = withoutResourceNodes;
12
+ exports.oneNodePerResource = oneNodePerResource;
13
+ exports.migrateSchema = migrateSchema;
14
+ exports.writeWorkflow = writeWorkflow;
15
+ exports.readWorkflow = readWorkflow;
16
+ exports.syncPipelineRefs = syncPipelineRefs;
17
+ exports.removeResourceOf = removeResourceOf;
18
+ exports.createPipelineOf = createPipelineOf;
19
+ exports.listsOf = listsOf;
20
+ const crypto_1 = __importDefault(require("crypto"));
21
+ const mongodb_1 = require("mongodb");
22
+ const manager_1 = require("../models/manager");
23
+ const nosqlDb_1 = require("../utils/nosqlDb");
24
+ const connection_1 = require("./connection");
25
+ const export_1 = require("./export");
26
+ const deliverables_1 = require("./deliverables");
27
+ const sanitize_1 = require("../utils/sanitize");
28
+ exports.NODE_OF = {
29
+ db: { type: 'dataSourceNode', field: 'connectionId', list: 'dbConnections', row: 'input', subtitle: r => r.dbType },
30
+ api: { type: 'connectionNode', field: 'connectionId', list: 'connections', row: 'input', subtitle: r => r.method ?? 'GET' },
31
+ storage: { type: 'ingestNode', field: 'storageConnectionId', list: 'storageConnections', row: 'input', subtitle: r => r.type },
32
+ service: { type: 'integrationConnectionNode', field: 'integrationConnectionId', list: 'integrationConnections', row: 'input', subtitle: r => r.provider },
33
+ export: { type: 'exportNode', field: 'exportId', list: 'exports', row: 'export', subtitle: r => r.method || 'GET' },
34
+ pipeline: { type: 'pipelineRefNode', field: 'pipelineId', list: 'pipelines', row: 'pipeline', subtitle: () => 'Pipeline' },
35
+ };
36
+ const KINDS = Object.keys(exports.NODE_OF);
37
+ const KIND_OF_TYPE = new Map(KINDS.map(kind => [exports.NODE_OF[kind].type, kind]));
38
+ // A node set to an integration the project doesn't have yet: connected when the workflow is saved.
39
+ exports.CONNECT_PREFIX = '__connect__:';
40
+ const pendingOf = (value) => (typeof value === 'string' && value.startsWith(exports.CONNECT_PREFIX) ? value.slice(exports.CONNECT_PREFIX.length) : null);
41
+ exports.pendingOf = pendingOf;
42
+ const ORCHESTRATOR = 'streamby';
43
+ const EDGE_STYLE = { stroke: '#38b6ff', strokeWidth: 1.5 };
44
+ const emptySchema = () => ({ nodes: [], edges: [] });
45
+ exports.emptySchema = emptySchema;
46
+ const schemaOf = (workflow) => ({
47
+ nodes: Array.isArray(workflow?.nodeSchema?.nodes) ? workflow.nodeSchema.nodes : [],
48
+ edges: Array.isArray(workflow?.nodeSchema?.edges) ? workflow.nodeSchema.edges : [],
49
+ });
50
+ exports.schemaOf = schemaOf;
51
+ /** The resource a node is bound to, if it's one of a resource's nodes and bound already. */
52
+ function resourceOfNode(node) {
53
+ const kind = KIND_OF_TYPE.get(node?.type);
54
+ const id = kind ? node?.data?.[exports.NODE_OF[kind].field] : null;
55
+ return kind && typeof id === 'string' && id && !pendingOf(id) ? { kind, id } : null;
56
+ }
57
+ /** The resources the workflow's nodes are bound to, by kind. */
58
+ function referencedIn(schema) {
59
+ const refs = Object.fromEntries(KINDS.map(kind => [kind, new Set()]));
60
+ for (const node of schema.nodes) {
61
+ const ref = resourceOfNode(node);
62
+ if (ref)
63
+ refs[ref.kind].add(ref.id);
64
+ }
65
+ return refs;
66
+ }
67
+ // What a node's data holds only to be shown (its name, how it's drawn), not what it does.
68
+ const PRESENTATION = new Set(['label', 'subtitle', 'icon', 'bgColor', 'iconColor', 'description']);
69
+ /**
70
+ * Whether a node consumes its resource rather than being it: it holds a configuration of its own
71
+ * beside the resource's id (a data source reading one table, an Ingest importing one file, a
72
+ * service node set to an action). A workflow may have many beside the resource's own node, and
73
+ * they're never merged into it.
74
+ */
75
+ function consumes(node) {
76
+ const kind = KIND_OF_TYPE.get(node?.type);
77
+ if (!kind)
78
+ return false;
79
+ return Object.entries(node?.data ?? {}).some(([key, value]) => key !== exports.NODE_OF[kind].field && !PRESENTATION.has(key) && value !== '' && value !== null && value !== undefined && !(typeof value === 'object' && !Object.keys(value).length));
80
+ }
81
+ /** Whether a node is its resource's own node (bound to it, and not one consuming it). */
82
+ const isOwnNode = (node, kind, id) => {
83
+ const ref = resourceOfNode(node);
84
+ return ref?.kind === kind && ref.id === id && !consumes(node);
85
+ };
86
+ exports.isOwnNode = isOwnNode;
87
+ // Where a new resource's node goes, as the canvas lays them out around the orchestrator: the
88
+ // inputs above it, the exports below, the pipelines on its left.
89
+ function placeFor(schema, row) {
90
+ const orchestrator = schema.nodes.find(n => n.id === ORCHESTRATOR)?.position ?? { x: 350, y: 20 };
91
+ const near = (a, b) => Math.abs(a - b) < 60;
92
+ if (row === 'pipeline') {
93
+ const x = orchestrator.x - 350;
94
+ const ys = schema.nodes.filter(n => n.position && near(n.position.x, x)).map(n => n.position.y);
95
+ return { x, y: ys.length ? Math.max(...ys) + 220 : orchestrator.y };
96
+ }
97
+ const y = row === 'input' ? orchestrator.y - 180 : orchestrator.y + 200;
98
+ const xs = schema.nodes.filter(n => n.position && near(n.position.y, y)).map(n => n.position.x);
99
+ return { x: xs.length ? Math.max(...xs) + 220 : orchestrator.x, y };
100
+ }
101
+ const orchestratorNode = () => ({ id: ORCHESTRATOR, type: 'orchestratorNode', position: { x: 350, y: 20 }, data: { label: 'StreamBy', subtitle: 'Orchestrator' } });
102
+ /**
103
+ * The schema with the resource's own node, if it has none yet (as the canvas wires one): nodes
104
+ * consuming it (an Ingest importing one of its files) aren't it, and removing them all mustn't
105
+ * remove it unasked.
106
+ */
107
+ function withResourceNode(schema, kind, resource) {
108
+ const id = String(resource.id);
109
+ if (schema.nodes.some(n => (0, exports.isOwnNode)(n, kind, id)))
110
+ return schema;
111
+ const def = exports.NODE_OF[kind];
112
+ const base = schema.nodes.some(n => n.id === ORCHESTRATOR) ? schema : { ...schema, nodes: [orchestratorNode(), ...schema.nodes] };
113
+ const nodeId = `${kind}-${id}-${crypto_1.default.randomUUID().slice(0, 8)}`;
114
+ const node = { id: nodeId, type: def.type, position: placeFor(base, def.row), data: { label: resource.name ?? id, subtitle: def.subtitle(resource), [def.field]: id } };
115
+ const edge = def.row === 'input'
116
+ ? { id: `e-${nodeId}`, source: nodeId, sourceHandle: 'out-stream', target: ORCHESTRATOR, targetHandle: 'in-top', animated: false, style: EDGE_STYLE }
117
+ : def.row === 'export'
118
+ ? { id: `e-${nodeId}`, source: ORCHESTRATOR, sourceHandle: 'out-bottom', target: nodeId, targetHandle: 'in-orchestrator-bottom', animated: true, style: EDGE_STYLE }
119
+ : { id: `e-${nodeId}`, source: ORCHESTRATOR, sourceHandle: 'out-pipeline', target: nodeId, targetHandle: 'in-orchestrator', animated: true, style: EDGE_STYLE };
120
+ return { nodes: [...base.nodes, node], edges: [...base.edges, edge] };
121
+ }
122
+ /** The schema without the resource's nodes, nor their edges. */
123
+ function withoutResourceNodes(schema, kind, id) {
124
+ const gone = new Set(schema.nodes.filter(n => { const ref = resourceOfNode(n); return ref?.kind === kind && ref.id === id; }).map(n => n.id));
125
+ return { nodes: schema.nodes.filter(n => !gone.has(n.id)), edges: schema.edges.filter(e => !gone.has(e.source) && !gone.has(e.target)) };
126
+ }
127
+ /** One node per resource: a second node bound to the same one merges into the first (its edges go to it). */
128
+ function oneNodePerResource(schema) {
129
+ const first = new Map();
130
+ const merged = new Map();
131
+ const nodes = schema.nodes.filter(node => {
132
+ const ref = resourceOfNode(node);
133
+ if (!ref || consumes(node))
134
+ return true;
135
+ const key = `${ref.kind}:${ref.id}`;
136
+ const kept = first.get(key);
137
+ if (!kept) {
138
+ first.set(key, node.id);
139
+ return true;
140
+ }
141
+ merged.set(node.id, kept);
142
+ return false;
143
+ });
144
+ const seen = new Set();
145
+ const edges = schema.edges
146
+ .map(e => ({ ...e, source: merged.get(e.source) ?? e.source, target: merged.get(e.target) ?? e.target }))
147
+ .filter(e => {
148
+ const key = `${e.source}|${e.sourceHandle ?? ''}|${e.target}|${e.targetHandle ?? ''}`;
149
+ if (e.source === e.target || seen.has(key))
150
+ return false;
151
+ seen.add(key);
152
+ return true;
153
+ });
154
+ return { nodes, edges };
155
+ }
156
+ const LEGACY_PREFIX = [['db-', 'db'], ['api-', 'api'], ['storage-', 'storage'], ['svc-', 'service'], ['export-', 'export'], ['pipeline-', 'pipeline']];
157
+ /**
158
+ * A workflow made before it defined the project, brought to it once: the nodes once drawn from
159
+ * the project's lists bound by the id in their data (an API's had it only in its node id), the
160
+ * orchestrator there, a node for each resource it has without one, and one node per resource.
161
+ */
162
+ function migrateSchema(project, schema) {
163
+ let nodes = schema.nodes.map(node => {
164
+ const legacy = LEGACY_PREFIX.find(([prefix, kind]) => typeof node?.id === 'string' && node.id.startsWith(prefix) && exports.NODE_OF[kind].type === node.type);
165
+ if (!legacy)
166
+ return node;
167
+ const def = exports.NODE_OF[legacy[1]];
168
+ return node.data?.[def.field] ? node : { ...node, data: { ...node.data, [def.field]: node.id.slice(legacy[0].length) } };
169
+ });
170
+ if (!nodes.some(n => n.id === ORCHESTRATOR))
171
+ nodes = [orchestratorNode(), ...nodes];
172
+ let migrated = { nodes, edges: schema.edges };
173
+ for (const kind of KINDS) {
174
+ for (const resource of (project[exports.NODE_OF[kind].list] ?? []))
175
+ migrated = withResourceNode(migrated, kind, resource);
176
+ }
177
+ return oneNodePerResource(migrated);
178
+ }
179
+ // ── Reading and writing a project's workflow ──
180
+ /** Saves the project's workflow (its single one, made if it has none) with the changes given. */
181
+ async function writeWorkflow(project, changes) {
182
+ const Project = (0, manager_1.getModel)('projects');
183
+ const projectId = String(project._id ?? project.id);
184
+ // Each save a later version than the one before, even within the same millisecond: a save is
185
+ // made on the version it was read at (baseUpdatedAt), so two can't share one.
186
+ const before = project.workflow?.updatedAt ? new Date(project.workflow.updatedAt).getTime() : 0;
187
+ const now = new Date(Math.max(Date.now(), before + 1));
188
+ const base = project.workflow ?? { id: crypto_1.default.randomUUID(), name: 'Workflow', description: '', status: 'draft', projectId, nodeSchema: null, createdAt: now };
189
+ const updated = { ...base, ...changes, id: base.id, projectId, updatedAt: now };
190
+ if (project.dbType === 'sql') {
191
+ await (0, manager_1.getModel)('projects', 'sql').update({ _id: projectId }, { workflow: updated });
192
+ }
193
+ else {
194
+ await Project.update({ _id: new mongodb_1.ObjectId(projectId) }, { $set: { workflow: updated } });
195
+ }
196
+ return updated;
197
+ }
198
+ /** The project's workflow as it's answered: brought once to define the project (see migrateSchema). */
199
+ async function readWorkflow(project) {
200
+ if (project.workflow?.resourceNodesAt)
201
+ return project.workflow;
202
+ const nodeSchema = migrateSchema(project, schemaOf(project.workflow));
203
+ return writeWorkflow(project, { nodeSchema, resourceNodesAt: new Date() });
204
+ }
205
+ // A change to the workflow saved, made on it as it is now (read again: another one may have
206
+ // been saved since the project was read).
207
+ async function changeSchema(projectId, change) {
208
+ const project = await (0, manager_1.getModel)('projects').findOne({ _id: projectId });
209
+ if (!project)
210
+ return;
211
+ const current = project.workflow?.resourceNodesAt ? schemaOf(project.workflow) : migrateSchema(project, schemaOf(project.workflow));
212
+ await writeWorkflow(project, { nodeSchema: change(current), resourceNodesAt: project.workflow?.resourceNodesAt ?? new Date() });
213
+ }
214
+ /** A resource added from its section (or by an agent): its node, in the workflow. */
215
+ const addToWorkflow = (projectId, kind, resource) => changeSchema(projectId, schema => withResourceNode(schema, kind, resource));
216
+ exports.addToWorkflow = addToWorkflow;
217
+ /** A resource removed from its section: its nodes, gone from the workflow. */
218
+ const removeFromWorkflow = (projectId, kind, id) => changeSchema(projectId, schema => withoutResourceNodes(schema, kind, id));
219
+ exports.removeFromWorkflow = removeFromWorkflow;
220
+ // ── Removing a resource ──
221
+ /** Rewrites ProjectInfo.pipelines from the pipelines collection, sorted by order. */
222
+ async function syncPipelineRefs(projectId) {
223
+ const db = (0, nosqlDb_1.nosqlDb)();
224
+ if (!db)
225
+ return;
226
+ const all = await db.collection('pipelines').find({ projectId }).project({ _id: 0, id: 1, name: 1, order: 1 }).sort({ order: 1, createdAt: 1 }).toArray();
227
+ await (0, manager_1.getModel)('projects').update({ _id: projectId }, { pipelines: all.map((p) => ({ id: p.id, name: p.name, order: p.order })) });
228
+ }
229
+ /**
230
+ * Removes a resource from the project, only it (its node is the caller's: the workflow saved
231
+ * without it, or removeFromWorkflow). A built-in disconnected keeps its id, to come back as it was.
232
+ */
233
+ async function removeResourceOf(project, kind, id, config) {
234
+ const Project = (0, manager_1.getModel)('projects');
235
+ const projectId = String(project._id ?? project.id);
236
+ if (kind === 'db' || kind === 'storage' || kind === 'service') {
237
+ const list = exports.NODE_OF[kind].list;
238
+ const conn = (project[list] ?? []).find((c) => c.id === id);
239
+ if (!conn)
240
+ return;
241
+ await Project.update({ _id: projectId }, { $pull: { [list]: { id } } });
242
+ if (conn.source === 'builtin' && conn.integrationId && kind !== 'service') {
243
+ await Project.update({ _id: projectId }, { $pull: { disconnectedBuiltins: { integrationId: conn.integrationId } } });
244
+ await Project.update({ _id: projectId }, { $push: { disconnectedBuiltins: { kind, integrationId: conn.integrationId, connectionId: conn.id } } });
245
+ }
246
+ }
247
+ else if (kind === 'api') {
248
+ if ((project.connections ?? []).some((c) => c.id === id))
249
+ await (0, connection_1.deleteConnection)(config, projectId, id);
250
+ }
251
+ else if (kind === 'export') {
252
+ if ((project.exports ?? []).some((e) => String(e.id) === id)) {
253
+ await (0, export_1.deleteExport)(config, projectId, id, project.dbType);
254
+ await (0, deliverables_1.deleteDeliverablesOf)(projectId, { exportId: id });
255
+ }
256
+ }
257
+ else {
258
+ await (0, nosqlDb_1.nosqlDb)()?.collection('pipelines').deleteOne({ id, projectId });
259
+ await syncPipelineRefs(projectId);
260
+ }
261
+ }
262
+ /** A pipeline made in the project (its refs kept in sync), as its section makes one. */
263
+ async function createPipelineOf(projectId, name, userId) {
264
+ const db = (0, nosqlDb_1.nosqlDb)();
265
+ if (!db)
266
+ throw new Error('Database not available');
267
+ const project = await (0, manager_1.getModel)('projects').findOne({ _id: projectId });
268
+ const now = new Date();
269
+ const pipeline = {
270
+ id: crypto_1.default.randomUUID(), projectId, name, description: null, order: project?.pipelines?.length ?? 0,
271
+ nodeSchema: null, createdBy: userId, createdAt: now, updatedAt: now,
272
+ };
273
+ await db.collection('pipelines').insertOne({ ...pipeline });
274
+ await syncPipelineRefs(projectId);
275
+ return pipeline;
276
+ }
277
+ /** The project's resource lists as they're answered after a change. */
278
+ async function listsOf(projectId) {
279
+ const project = await (0, manager_1.getModel)('projects').findOne({ _id: projectId });
280
+ return {
281
+ dbConnections: (0, sanitize_1.sanitizeConnections)(project?.dbConnections),
282
+ storageConnections: (0, sanitize_1.sanitizeConnections)(project?.storageConnections),
283
+ integrationConnections: (0, sanitize_1.sanitizeConnections)(project?.integrationConnections),
284
+ connections: (0, sanitize_1.sanitizeConnections)(project?.connections),
285
+ exports: (0, sanitize_1.exportSummaries)(project?.exports),
286
+ pipelines: project?.pipelines ?? [],
287
+ };
288
+ }
@@ -82,6 +82,11 @@ export interface ProjectInfo {
82
82
  integrationConnections?: IntegrationConnection[];
83
83
  pipelines?: PipelineRef[];
84
84
  builtinBackfilledAt?: Date;
85
+ disconnectedBuiltins?: {
86
+ kind: 'db' | 'storage';
87
+ integrationId: string;
88
+ connectionId: string;
89
+ }[];
85
90
  workflow?: Workflow;
86
91
  }
87
92
  export type NodePersistence = 'database' | 'storage' | 'both' | 'none';
@@ -541,7 +546,7 @@ export interface DocumentAuthor {
541
546
  clientId?: string;
542
547
  clientName?: string;
543
548
  }
544
- export type DocumentKind = 'document' | 'folder';
549
+ export type DocumentKind = 'document' | 'folder' | 'credits';
545
550
  /** A project's document as it is now (a folder only groups others: it has no content). */
546
551
  export interface ProjectDocument {
547
552
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",