@anchrd/intel 0.59.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.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
|
|
2
|
+
import { BundleImportResult, BundleManifest, BundleManifestFilename, } from "../contract/bundle.js";
|
|
3
|
+
import { FlowGraph } from "../contract/flow.js";
|
|
4
|
+
import { BlockNoteMediaType, DocumentLinkInlineType, } from "../contract/node.js";
|
|
5
|
+
import { TableMediaType } from "../contract/table.js";
|
|
6
|
+
import { documentLinkTargets } from "../nodes/document-links/document-links.js";
|
|
7
|
+
import { parseCsv } from "../shared/csv/csv.js";
|
|
8
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
9
|
+
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
10
|
+
/**
|
|
11
|
+
* What every bundle leaves out, by decision rather than by accident (#136, ADR-0006). The manifest
|
|
12
|
+
* says so, because a backup that is silent about what it does not hold will be trusted with exactly
|
|
13
|
+
* that.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ ONE list, read by both halves of the round trip: the export writes it into `manifest.json`,
|
|
16
|
+
* and the import answers with it in `BundleImportResult.excluded` (#433). The import does NOT read
|
|
17
|
+
* the word back out of the manifest it was handed — a bundle claiming `excluded: []` would
|
|
18
|
+
* otherwise make the import report that nothing was left behind, and a naked folder carries no
|
|
19
|
+
* manifest to ask at all. Every one of the five holds for every import this build performs: a node
|
|
20
|
+
* arrives with `sequence: 1` and nothing older, an import mints no grants, it starts no runs,
|
|
21
|
+
* nothing it creates is archived, and a published flow's graph is the one that was published.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ `unpublished-flow-changes` is a CONSTANT of the build like the other four, not a report about
|
|
24
|
+
* this particular bundle (#585). It says what a bundle never carries — the edits made to a flow
|
|
25
|
+
* after it was published — in the same way `version-history` says so for a node with a single
|
|
26
|
+
* version. A list that named only the losses this one export happens to suffer would be a different
|
|
27
|
+
* kind of statement, and the import could not make it at all: it answers before it knows what is in
|
|
28
|
+
* the zip, and from a naked folder it never finds out.
|
|
29
|
+
*/
|
|
30
|
+
const Excluded = [
|
|
31
|
+
"version-history",
|
|
32
|
+
"grants",
|
|
33
|
+
"flow-runs",
|
|
34
|
+
"archived-nodes",
|
|
35
|
+
"unpublished-flow-changes",
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* A title as a file name. Titles are free text and zip paths are not: a separator would move the
|
|
39
|
+
* entry, a control character corrupts the archive listing, and a trailing dot breaks extraction on
|
|
40
|
+
* Windows. What cannot be kept is replaced rather than refused — an export must not fail because of
|
|
41
|
+
* how something was named.
|
|
42
|
+
*/
|
|
43
|
+
function sanitizeName(title) {
|
|
44
|
+
const cleaned = title
|
|
45
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what has to go
|
|
46
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
47
|
+
.replace(/[/\\]/g, "-")
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/^\.+/, "")
|
|
50
|
+
.replace(/[. ]+$/, "")
|
|
51
|
+
.slice(0, 150);
|
|
52
|
+
return cleaned.length > 0 ? cleaned : "untitled";
|
|
53
|
+
}
|
|
54
|
+
// A name that is free in this directory. Case-insensitive, because the zip will be extracted onto
|
|
55
|
+
// filesystems where "Notes.md" and "notes.md" are the same file and the second would overwrite the
|
|
56
|
+
// first without a word.
|
|
57
|
+
function uniqueName(used, base, extension) {
|
|
58
|
+
const candidate = base.toLowerCase().endsWith(extension.toLowerCase())
|
|
59
|
+
? base.slice(0, base.length - extension.length)
|
|
60
|
+
: base;
|
|
61
|
+
const first = `${candidate}${extension}`;
|
|
62
|
+
if (!used.has(first.toLowerCase())) {
|
|
63
|
+
used.add(first.toLowerCase());
|
|
64
|
+
return first;
|
|
65
|
+
}
|
|
66
|
+
for (let counter = 2;; counter++) {
|
|
67
|
+
const next = `${candidate} (${counter})${extension}`;
|
|
68
|
+
if (!used.has(next.toLowerCase())) {
|
|
69
|
+
used.add(next.toLowerCase());
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ── Import (#137) ───────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// The import limits, explicit rather than discovered (#137). The zip stream and the unpacked total
|
|
76
|
+
// are both bounded because the Worker holds an entry in memory while it lands: a bundle is parsed
|
|
77
|
+
// where it arrives, and an isolate has 128 MB for everything. The old 15 MB base64 door stays for
|
|
78
|
+
// single attachments; these carry a real bundle several times that size.
|
|
79
|
+
export const MaxImportZipBytes = 64 * 1024 * 1024;
|
|
80
|
+
export const MaxImportEntryBytes = 48 * 1024 * 1024;
|
|
81
|
+
const MaxImportTotalBytes = 96 * 1024 * 1024;
|
|
82
|
+
const MaxImportEntries = 1000;
|
|
83
|
+
// The same key shape `nodes.ts` writes: keys are stored, never derived back, so agreeing on the
|
|
84
|
+
// format costs nothing while a second format would split the bucket for no reason.
|
|
85
|
+
function contentKeyFor(nodeId, versionId) {
|
|
86
|
+
return `nodes/${nodeId}/versions/${versionId}`;
|
|
87
|
+
}
|
|
88
|
+
// Files archivers add that nobody filed: importing them would put macOS bookkeeping into the tree.
|
|
89
|
+
const JunkNames = new Set([".DS_Store", "Thumbs.db"]);
|
|
90
|
+
function isJunkPath(path) {
|
|
91
|
+
const segments = path.replace(/\/$/, "").split("/");
|
|
92
|
+
return segments[0] === "__MACOSX" || JunkNames.has(segments[segments.length - 1] ?? "");
|
|
93
|
+
}
|
|
94
|
+
// A zip entry name is attacker-controlled text, and "../" in one is how an archive writes outside
|
|
95
|
+
// its directory. Nothing here touches a filesystem, but a traversal segment would still corrupt
|
|
96
|
+
// the parent lookup, so the path is refused rather than interpreted.
|
|
97
|
+
function requireSafePath(path) {
|
|
98
|
+
if (path.includes("\\") || path.startsWith("/")) {
|
|
99
|
+
throw new IntelError(400, "import_invalid_path", `Bundle path is not usable: ${path}`);
|
|
100
|
+
}
|
|
101
|
+
for (const segment of path.replace(/\/$/, "").split("/")) {
|
|
102
|
+
if (segment === "" || segment === "." || segment === "..") {
|
|
103
|
+
throw new IntelError(400, "import_invalid_path", `Bundle path is not usable: ${path}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function concatChunks(chunks, size) {
|
|
108
|
+
const joined = new Uint8Array(size);
|
|
109
|
+
let offset = 0;
|
|
110
|
+
for (const chunk of chunks) {
|
|
111
|
+
joined.set(chunk, offset);
|
|
112
|
+
offset += chunk.length;
|
|
113
|
+
}
|
|
114
|
+
return joined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The whole zip, read incrementally: the compressed stream is fed chunk by chunk and every entry
|
|
118
|
+
* is collected as it completes, so the peak cost is the unpacked entries — bounded above — and
|
|
119
|
+
* never compressed-plus-unpacked at once.
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ A truncated archive does not fail loudly in fflate: entries simply never finish. The open
|
|
122
|
+
* counter turns that silence into the refusal it is — a bundle that ends mid-entry imports
|
|
123
|
+
* nothing (#137, Abbruch-Test).
|
|
124
|
+
*/
|
|
125
|
+
async function readZip(source) {
|
|
126
|
+
const files = new Map();
|
|
127
|
+
const directories = new Set();
|
|
128
|
+
let failure = null;
|
|
129
|
+
let unpackedTotal = 0;
|
|
130
|
+
let entryCount = 0;
|
|
131
|
+
let openEntries = 0;
|
|
132
|
+
const invalid = () => new IntelError(400, "import_invalid_bundle", "The zip archive could not be read");
|
|
133
|
+
const unzip = new Unzip((file) => {
|
|
134
|
+
if (failure)
|
|
135
|
+
return;
|
|
136
|
+
const name = file.name;
|
|
137
|
+
if (name.endsWith("/")) {
|
|
138
|
+
directories.add(name);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
entryCount += 1;
|
|
142
|
+
if (entryCount > MaxImportEntries) {
|
|
143
|
+
failure = new IntelError(413, "import_too_many_entries", `The bundle exceeds ${MaxImportEntries} entries`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
openEntries += 1;
|
|
147
|
+
const chunks = [];
|
|
148
|
+
let size = 0;
|
|
149
|
+
file.ondata = (error, chunk, final) => {
|
|
150
|
+
if (failure)
|
|
151
|
+
return;
|
|
152
|
+
if (error) {
|
|
153
|
+
failure = invalid();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (chunk) {
|
|
157
|
+
size += chunk.length;
|
|
158
|
+
unpackedTotal += chunk.length;
|
|
159
|
+
if (size > MaxImportEntryBytes) {
|
|
160
|
+
failure = new IntelError(413, "import_entry_too_large", `A bundle entry exceeds ${MaxImportEntryBytes} bytes unpacked: ${name}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (unpackedTotal > MaxImportTotalBytes) {
|
|
164
|
+
failure = new IntelError(413, "import_too_large", `The bundle exceeds ${MaxImportTotalBytes} bytes unpacked`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
chunks.push(chunk);
|
|
168
|
+
}
|
|
169
|
+
if (final) {
|
|
170
|
+
openEntries -= 1;
|
|
171
|
+
files.set(name, concatChunks(chunks, size));
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
file.start();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
failure = invalid();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
unzip.register(UnzipInflate);
|
|
182
|
+
const push = (chunk, final) => {
|
|
183
|
+
try {
|
|
184
|
+
unzip.push(chunk, final);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
failure = failure ?? invalid();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
if (source instanceof Uint8Array) {
|
|
191
|
+
if (source.length > MaxImportZipBytes) {
|
|
192
|
+
throw new IntelError(413, "import_too_large", `The zip exceeds ${MaxImportZipBytes} bytes`);
|
|
193
|
+
}
|
|
194
|
+
push(source, true);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const reader = source.getReader();
|
|
198
|
+
let compressed = 0;
|
|
199
|
+
for (;;) {
|
|
200
|
+
const { done, value } = await reader.read();
|
|
201
|
+
if (done)
|
|
202
|
+
break;
|
|
203
|
+
compressed += value.length;
|
|
204
|
+
if (compressed > MaxImportZipBytes) {
|
|
205
|
+
throw new IntelError(413, "import_too_large", `The zip exceeds ${MaxImportZipBytes} bytes`);
|
|
206
|
+
}
|
|
207
|
+
push(value, false);
|
|
208
|
+
if (failure)
|
|
209
|
+
throw failure;
|
|
210
|
+
}
|
|
211
|
+
push(new Uint8Array(0), true);
|
|
212
|
+
}
|
|
213
|
+
if (failure)
|
|
214
|
+
throw failure;
|
|
215
|
+
if (openEntries > 0 || (files.size === 0 && directories.size === 0))
|
|
216
|
+
throw invalid();
|
|
217
|
+
return { files, directories };
|
|
218
|
+
}
|
|
219
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
220
|
+
function decodeText(body, path) {
|
|
221
|
+
try {
|
|
222
|
+
return decoder.decode(body);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new IntelError(400, "import_invalid_text", `Bundle entry is not UTF-8: ${path}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function depthOf(path) {
|
|
229
|
+
return path.replace(/\/$/, "").split("/").length;
|
|
230
|
+
}
|
|
231
|
+
function directoryOf(path) {
|
|
232
|
+
const trimmed = path.replace(/\/$/, "");
|
|
233
|
+
const cut = trimmed.lastIndexOf("/");
|
|
234
|
+
return cut === -1 ? "" : trimmed.slice(0, cut + 1);
|
|
235
|
+
}
|
|
236
|
+
// What a file extension says a naked upload is (#137): the three kinds a name can carry, and
|
|
237
|
+
// attachment for everything else. Only used without a manifest — a manifest already says.
|
|
238
|
+
function nakedKindOf(name) {
|
|
239
|
+
const lower = name.toLowerCase();
|
|
240
|
+
if (lower.endsWith(".md")) {
|
|
241
|
+
return { kind: "document", mediaType: "text/markdown", title: name.slice(0, -3) };
|
|
242
|
+
}
|
|
243
|
+
if (lower.endsWith(".csv")) {
|
|
244
|
+
return { kind: "table", mediaType: TableMediaType, title: name.slice(0, -4) };
|
|
245
|
+
}
|
|
246
|
+
// ⚠️ `.board.json` had its own rule here until #390 and deliberately does not any more. This is
|
|
247
|
+
// the NAKED-folder path — a directory somebody picked, with no manifest — so a file called
|
|
248
|
+
// `plan.board.json` is a JSON file on a disk, and keeping it as an attachment preserves it. A
|
|
249
|
+
// real export is a different matter: its manifest declares the kind, and that is where such an
|
|
250
|
+
// entry is refused by name.
|
|
251
|
+
return { kind: "attachment", mediaType: guessedMediaType(lower), title: name };
|
|
252
|
+
}
|
|
253
|
+
const KnownMediaTypes = {
|
|
254
|
+
".pdf": "application/pdf",
|
|
255
|
+
".png": "image/png",
|
|
256
|
+
".jpg": "image/jpeg",
|
|
257
|
+
".jpeg": "image/jpeg",
|
|
258
|
+
".gif": "image/gif",
|
|
259
|
+
".svg": "image/svg+xml",
|
|
260
|
+
".txt": "text/plain",
|
|
261
|
+
".json": "application/json",
|
|
262
|
+
".zip": "application/zip",
|
|
263
|
+
};
|
|
264
|
+
function guessedMediaType(lowerName) {
|
|
265
|
+
const dot = lowerName.lastIndexOf(".");
|
|
266
|
+
return ((dot === -1 ? undefined : KnownMediaTypes[lowerName.slice(dot)]) ?? "application/octet-stream");
|
|
267
|
+
}
|
|
268
|
+
function isRecord(value) {
|
|
269
|
+
return typeof value === "object" && value !== null;
|
|
270
|
+
}
|
|
271
|
+
// The link rewrite of #137: every `documentLink` inline element whose target the bundle carried is
|
|
272
|
+
// pointed at the node the import just made; a link to a node outside the bundle stays as written.
|
|
273
|
+
// Tolerant like `documentLinkTargets`: what is not recognised is carried over unchanged.
|
|
274
|
+
function remapDocumentLinks(content, idMap) {
|
|
275
|
+
let parsed;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(content);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return content;
|
|
281
|
+
}
|
|
282
|
+
function walk(value) {
|
|
283
|
+
if (Array.isArray(value)) {
|
|
284
|
+
for (const entry of value)
|
|
285
|
+
walk(entry);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (!isRecord(value))
|
|
289
|
+
return;
|
|
290
|
+
if (value.type === DocumentLinkInlineType && isRecord(value.props)) {
|
|
291
|
+
const target = value.props.nodeId;
|
|
292
|
+
if (typeof target === "string" && idMap.has(target)) {
|
|
293
|
+
value.props.nodeId = idMap.get(target);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// `blocks` is the document's own top level; `content` and `children` are where a block hides
|
|
297
|
+
// inline elements — the same three places `documentLinkTargets` reads.
|
|
298
|
+
walk(value.blocks);
|
|
299
|
+
walk(value.content);
|
|
300
|
+
walk(value.children);
|
|
301
|
+
}
|
|
302
|
+
walk(parsed);
|
|
303
|
+
return JSON.stringify(parsed);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The graph rewrite of #137: link nodes point at the imported copies of what they named, a subflow
|
|
307
|
+
* calls the imported copy of its callee. References to things outside the bundle stay untouched.
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ A `pinned` sub-flow version cannot survive the remap of its callee: the pinned ID names a row
|
|
310
|
+
* of the OLD installation's history, which this import deliberately does not recreate. The call
|
|
311
|
+
* falls back to `latest` — the draft default — and publishing will pin it anew.
|
|
312
|
+
*/
|
|
313
|
+
function remapFlowGraph(graph, idMap) {
|
|
314
|
+
return {
|
|
315
|
+
...graph,
|
|
316
|
+
nodes: graph.nodes.map((node) => {
|
|
317
|
+
if ((node.kind === "folder" ||
|
|
318
|
+
node.kind === "document" ||
|
|
319
|
+
node.kind === "upload" ||
|
|
320
|
+
node.kind === "table") &&
|
|
321
|
+
idMap.has(node.configuration.resourceId)) {
|
|
322
|
+
return {
|
|
323
|
+
...node,
|
|
324
|
+
configuration: {
|
|
325
|
+
...node.configuration,
|
|
326
|
+
resourceId: idMap.get(node.configuration.resourceId),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (node.kind === "subflow" && idMap.has(node.configuration.flowId)) {
|
|
331
|
+
return {
|
|
332
|
+
...node,
|
|
333
|
+
configuration: {
|
|
334
|
+
flowId: idMap.get(node.configuration.flowId),
|
|
335
|
+
version: node.configuration.version.mode === "pinned"
|
|
336
|
+
? { mode: "latest" }
|
|
337
|
+
: node.configuration.version,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return node;
|
|
342
|
+
}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function flowGraphJson(version) {
|
|
346
|
+
// A flow that was never saved has no graph; its entry is an empty file rather than an invented
|
|
347
|
+
// graph, and the import creates the same empty flow back.
|
|
348
|
+
return version === undefined ? "" : JSON.stringify(version.graph, null, 2);
|
|
349
|
+
}
|
|
350
|
+
export function createBundle(deps) {
|
|
351
|
+
// Flow visibility is the flow repository's question and takes its actor shape. `canRun` gates
|
|
352
|
+
// starting runs, which no export does, so it is answered with the conservative constant.
|
|
353
|
+
function asFlowActor(actor) {
|
|
354
|
+
return {
|
|
355
|
+
id: actor.id,
|
|
356
|
+
email: actor.email,
|
|
357
|
+
canRun: false,
|
|
358
|
+
...(actor.isAdmin === undefined ? {} : { isAdmin: actor.isAdmin }),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function tableCsv(nodeId) {
|
|
362
|
+
const keys = await deps.repository.listVersionContentKeys(nodeId);
|
|
363
|
+
// ⚠️ `some` on an empty list is false, so no segments at all used to walk past the guard below
|
|
364
|
+
// and return `[].join("")` — the empty string, written into the zip as a file of zero bytes
|
|
365
|
+
// (#534). A table that has never been defined no longer reaches this function at all: it has no
|
|
366
|
+
// current version, and `plannedNode` marks it absent instead. An empty list HERE therefore means
|
|
367
|
+
// something else — a current version whose segments are gone — which is the loss the guard below
|
|
368
|
+
// already names, and it deserves the same answer rather than a silently empty file.
|
|
369
|
+
if (keys.length === 0) {
|
|
370
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
371
|
+
}
|
|
372
|
+
const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
|
|
373
|
+
if (segments.some((segment) => segment === null)) {
|
|
374
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
375
|
+
}
|
|
376
|
+
return segments.join("");
|
|
377
|
+
}
|
|
378
|
+
function textLoader(contentKey) {
|
|
379
|
+
return async () => {
|
|
380
|
+
const body = await deps.content.get(contentKey);
|
|
381
|
+
if (body === null) {
|
|
382
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
383
|
+
}
|
|
384
|
+
return body;
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The bytes of an attachment version, or the same refusal the text path has always given (#560).
|
|
389
|
+
*
|
|
390
|
+
* ⚠️ A `contentKey` in the row and nothing behind it in R2 is data LOSS, and the attachment path
|
|
391
|
+
* was the one path that answered it with silence: `getStream` returned `null`, the zip got a file
|
|
392
|
+
* of zero bytes, and the export reported success. Whoever filed that zip away as a backup learns
|
|
393
|
+
* what it holds when they restore it — the one moment nothing can be done about it any more.
|
|
394
|
+
*
|
|
395
|
+
* This is deliberately NOT the `absence` answer. "Never uploaded" is a state of the node and
|
|
396
|
+
* travels in the manifest; "the object is gone" is a broken installation, and a bundle must not
|
|
397
|
+
* be able to describe it as if it were fine. Same distinction as `textLoader` and `tableCsv`.
|
|
398
|
+
*/
|
|
399
|
+
function streamLoader(contentKey) {
|
|
400
|
+
return async () => {
|
|
401
|
+
const stream = await deps.content.getStream(contentKey);
|
|
402
|
+
if (stream === null) {
|
|
403
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
404
|
+
}
|
|
405
|
+
return stream;
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function plannedNode(row, directory, used) {
|
|
409
|
+
const { node, version } = row;
|
|
410
|
+
const base = sanitizeName(node.title);
|
|
411
|
+
if (node.kind === "folder") {
|
|
412
|
+
const name = uniqueName(used, base, "");
|
|
413
|
+
const path = `${directory}${name}/`;
|
|
414
|
+
return {
|
|
415
|
+
manifest: {
|
|
416
|
+
id: node.id,
|
|
417
|
+
kind: "folder",
|
|
418
|
+
title: node.title,
|
|
419
|
+
description: node.description,
|
|
420
|
+
mediaType: null,
|
|
421
|
+
path,
|
|
422
|
+
},
|
|
423
|
+
content: { type: "folder" },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (node.kind === "table") {
|
|
427
|
+
const path = `${directory}${uniqueName(used, base, ".csv")}`;
|
|
428
|
+
// ⚠️ A table with no current version has never been given a header, and for a table the
|
|
429
|
+
// canonical content IS the CSV: there is nothing to write. It used to travel as a file of zero
|
|
430
|
+
// bytes, which the holder of the zip cannot tell apart from a table whose content was lost on
|
|
431
|
+
// the way — the very case `tableCsv` refuses as `content_missing`. One file, two meanings, no
|
|
432
|
+
// way to read which (#534).
|
|
433
|
+
//
|
|
434
|
+
// ⚠️ The entry STAYS in the manifest, and that is the half that matters: dropping it would
|
|
435
|
+
// take the node's title, its description and its place in the tree out of the backup too, and
|
|
436
|
+
// the import would recreate a subtree with a node missing from it without anybody noticing.
|
|
437
|
+
// What travels instead is the node without bytes, plus the word for what is not there.
|
|
438
|
+
if (version === null) {
|
|
439
|
+
return {
|
|
440
|
+
manifest: {
|
|
441
|
+
id: node.id,
|
|
442
|
+
kind: "table",
|
|
443
|
+
title: node.title,
|
|
444
|
+
description: node.description,
|
|
445
|
+
mediaType: TableMediaType,
|
|
446
|
+
path,
|
|
447
|
+
absence: "no-content",
|
|
448
|
+
},
|
|
449
|
+
content: { type: "absent" },
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
manifest: {
|
|
454
|
+
id: node.id,
|
|
455
|
+
kind: "table",
|
|
456
|
+
title: node.title,
|
|
457
|
+
description: node.description,
|
|
458
|
+
mediaType: TableMediaType,
|
|
459
|
+
path,
|
|
460
|
+
},
|
|
461
|
+
content: { type: "text", load: async () => await tableCsv(node.id) },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (node.kind === "attachment") {
|
|
465
|
+
// Original bytes under the original name: an attachment's title IS its file name, so no
|
|
466
|
+
// extension is imposed on it.
|
|
467
|
+
const path = `${directory}${uniqueName(used, base, "")}`;
|
|
468
|
+
// ⚠️ An attachment with no current version was created and never uploaded to, and for an
|
|
469
|
+
// attachment the canonical content IS the bytes: there is nothing to write. It used to travel
|
|
470
|
+
// as a file of zero bytes — the same one file with two meanings the table branch above
|
|
471
|
+
// refuses, and here the second meaning is a lost R2 object (#560).
|
|
472
|
+
//
|
|
473
|
+
// ⚠️ The entry STAYS in the manifest for the same reason it does for a table: dropping it
|
|
474
|
+
// would take the node's title, its description and its place in the tree out of the backup,
|
|
475
|
+
// and the import would rebuild a subtree with a node missing from it and say nothing.
|
|
476
|
+
if (version === null) {
|
|
477
|
+
return {
|
|
478
|
+
manifest: {
|
|
479
|
+
id: node.id,
|
|
480
|
+
kind: "attachment",
|
|
481
|
+
title: node.title,
|
|
482
|
+
description: node.description,
|
|
483
|
+
mediaType: "application/octet-stream",
|
|
484
|
+
path,
|
|
485
|
+
absence: "no-content",
|
|
486
|
+
},
|
|
487
|
+
content: { type: "absent" },
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
manifest: {
|
|
492
|
+
id: node.id,
|
|
493
|
+
kind: "attachment",
|
|
494
|
+
title: node.title,
|
|
495
|
+
description: node.description,
|
|
496
|
+
mediaType: version.mediaType ?? "application/octet-stream",
|
|
497
|
+
path,
|
|
498
|
+
},
|
|
499
|
+
content: { type: "stream", load: streamLoader(version.contentKey) },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
// ⚠️ A row of a kind this build no longer makes — an `agent` or a `board` written before #390
|
|
503
|
+
// — falls through to the document branch and travels as its raw body. Deliberately forgiving:
|
|
504
|
+
// refusing here would make one leftover row enough to stop a whole subtree from being exported,
|
|
505
|
+
// and the file it produces is exactly the bytes that were stored. The IMPORT is where such a
|
|
506
|
+
// bundle is turned away, by name.
|
|
507
|
+
// A document: markdown stays `.md`, a BlockNote body is JSON and says so with its name.
|
|
508
|
+
const mediaType = version?.mediaType ?? "text/markdown";
|
|
509
|
+
const extension = mediaType === BlockNoteMediaType ? ".json" : ".md";
|
|
510
|
+
const path = `${directory}${uniqueName(used, base, extension)}`;
|
|
511
|
+
return {
|
|
512
|
+
manifest: {
|
|
513
|
+
id: node.id,
|
|
514
|
+
kind: "document",
|
|
515
|
+
title: node.title,
|
|
516
|
+
description: node.description,
|
|
517
|
+
mediaType,
|
|
518
|
+
path,
|
|
519
|
+
},
|
|
520
|
+
content: version === null
|
|
521
|
+
? { type: "text", load: async () => "" }
|
|
522
|
+
: { type: "text", load: textLoader(version.contentKey) },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Which graph of a flow travels: the PUBLISHED one when the flow has one, the draft otherwise.
|
|
527
|
+
* Three call sites read it through this function rather than spelling the fallback out, because
|
|
528
|
+
* it is one decision and not three (#585).
|
|
529
|
+
*
|
|
530
|
+
* ⚠️ For a flow that was published and edited afterwards, this deliberately leaves the newer draft
|
|
531
|
+
* behind — and that is NOT the `version-history` case, which is why `Excluded` names it with a
|
|
532
|
+
* word of its own. The draft is the newest state there is; what decides against it is that the
|
|
533
|
+
* import cannot carry both. It creates rather than restores (ADR-0006): every flow arrives as a
|
|
534
|
+
* draft with one version and `publishedVersionId: null`, so "published v1 plus draft v2" has no
|
|
535
|
+
* shape on the far side, and carrying both would be flow version history — the thing that ADR
|
|
536
|
+
* refuses for exactly the reasons it lists.
|
|
537
|
+
*
|
|
538
|
+
* Of the two single answers the published one wins because it is the one that was IN EFFECT. A
|
|
539
|
+
* draft need never have been validated — publishing is what checks a graph — so moving the draft
|
|
540
|
+
* instead would replace a running process with somebody's unfinished edit, silently, at the one
|
|
541
|
+
* moment nobody can compare against the source any more.
|
|
542
|
+
*/
|
|
543
|
+
function exportedVersionId(flow) {
|
|
544
|
+
return flow.publishedVersionId ?? flow.currentVersionId;
|
|
545
|
+
}
|
|
546
|
+
function plannedFlow(flow, version, directory, used) {
|
|
547
|
+
const path = `${directory}${uniqueName(used, sanitizeName(flow.title), ".json")}`;
|
|
548
|
+
return {
|
|
549
|
+
manifest: {
|
|
550
|
+
id: flow.id,
|
|
551
|
+
kind: "flow",
|
|
552
|
+
title: flow.title,
|
|
553
|
+
description: flow.description,
|
|
554
|
+
mediaType: "application/json",
|
|
555
|
+
path,
|
|
556
|
+
},
|
|
557
|
+
content: { type: "text", load: async () => flowGraphJson(version) },
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The plan of one export: every entry with its path, parents before children.
|
|
562
|
+
*
|
|
563
|
+
* The tree is walked from the chosen root through what `listVisibleSubtree` answered — one
|
|
564
|
+
* statement carrying the same `allowed` predicate every other read uses, so a node without read
|
|
565
|
+
* is neither in the zip nor mentioned in the manifest (#136). Flows ride along by the folder they
|
|
566
|
+
* are filed in, only when the caller holds `flows/read` at all, and their graph is the published
|
|
567
|
+
* version when there is one — the frozen state — falling back to the current draft.
|
|
568
|
+
*/
|
|
569
|
+
async function plan(actor, rootId) {
|
|
570
|
+
if (rootId !== null) {
|
|
571
|
+
const root = await deps.repository.getVisible(actor, rootId);
|
|
572
|
+
// An archived node is out of every bundle by decision, and "archived" must not be
|
|
573
|
+
// distinguishable from "absent" through this door either.
|
|
574
|
+
if (!root || root.archivedAt !== null) {
|
|
575
|
+
throw new IntelError(404, "node_not_found", "Node was not found");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const rows = await deps.repository.listVisibleSubtree(actor, rootId);
|
|
579
|
+
const byParent = new Map();
|
|
580
|
+
const included = new Set(rows.map((row) => row.node.id));
|
|
581
|
+
for (const row of rows) {
|
|
582
|
+
// The export root's own parent is outside the bundle; it anchors at the zip root.
|
|
583
|
+
const key = row.node.id === rootId || !row.node.parentId || !included.has(row.node.parentId)
|
|
584
|
+
? null
|
|
585
|
+
: row.node.parentId;
|
|
586
|
+
const level = byParent.get(key) ?? [];
|
|
587
|
+
level.push(row);
|
|
588
|
+
byParent.set(key, level);
|
|
589
|
+
}
|
|
590
|
+
const flowsByParent = new Map();
|
|
591
|
+
const flowVersions = new Map();
|
|
592
|
+
if (actor.canReadFlows) {
|
|
593
|
+
const folderIds = new Set(rows.filter((row) => row.node.kind === "folder").map((row) => row.node.id));
|
|
594
|
+
const visible = (await deps.flows.listVisible(asFlowActor(actor))).filter((flow) => flow.parentId === null ? rootId === null : folderIds.has(flow.parentId));
|
|
595
|
+
const versionIds = visible.map(exportedVersionId).filter((id) => id !== null);
|
|
596
|
+
for (const version of await deps.flows.getVersions(versionIds)) {
|
|
597
|
+
flowVersions.set(version.id, version);
|
|
598
|
+
}
|
|
599
|
+
for (const flow of visible) {
|
|
600
|
+
const level = flowsByParent.get(flow.parentId) ?? [];
|
|
601
|
+
level.push(flow);
|
|
602
|
+
flowsByParent.set(flow.parentId, level);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const entries = [];
|
|
606
|
+
// Folders first, then titles, the order the tree itself draws — so the zip listing reads like
|
|
607
|
+
// the sidebar. Ties fall back to the id to stay deterministic.
|
|
608
|
+
function walk(parentKey, directory) {
|
|
609
|
+
const level = [
|
|
610
|
+
...(byParent.get(parentKey) ?? []).map((row) => ({ row, flow: null })),
|
|
611
|
+
...(flowsByParent.get(parentKey) ?? []).map((flow) => ({
|
|
612
|
+
row: null,
|
|
613
|
+
flow,
|
|
614
|
+
})),
|
|
615
|
+
].sort((left, right) => {
|
|
616
|
+
const leftFolder = left.row?.node.kind === "folder" ? 0 : 1;
|
|
617
|
+
const rightFolder = right.row?.node.kind === "folder" ? 0 : 1;
|
|
618
|
+
const leftTitle = left.row?.node.title ?? left.flow?.title ?? "";
|
|
619
|
+
const rightTitle = right.row?.node.title ?? right.flow?.title ?? "";
|
|
620
|
+
const leftId = left.row?.node.id ?? left.flow?.id ?? "";
|
|
621
|
+
const rightId = right.row?.node.id ?? right.flow?.id ?? "";
|
|
622
|
+
return (leftFolder - rightFolder ||
|
|
623
|
+
leftTitle.toLowerCase().localeCompare(rightTitle.toLowerCase()) ||
|
|
624
|
+
leftId.localeCompare(rightId));
|
|
625
|
+
});
|
|
626
|
+
const used = new Set();
|
|
627
|
+
for (const item of level) {
|
|
628
|
+
if (item.row !== null) {
|
|
629
|
+
const entry = plannedNode(item.row, directory, used);
|
|
630
|
+
entries.push(entry);
|
|
631
|
+
if (item.row.node.kind === "folder") {
|
|
632
|
+
walk(item.row.node.id, entry.manifest.path);
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (item.flow !== null) {
|
|
637
|
+
const versionId = exportedVersionId(item.flow);
|
|
638
|
+
entries.push(plannedFlow(item.flow, versionId === null ? undefined : flowVersions.get(versionId), directory, used));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
walk(null, "");
|
|
643
|
+
return entries;
|
|
644
|
+
}
|
|
645
|
+
function manifestOf(rootId, entries) {
|
|
646
|
+
return {
|
|
647
|
+
version: 1,
|
|
648
|
+
exportedAt: deps.now().toISOString(),
|
|
649
|
+
rootId,
|
|
650
|
+
entries: entries.map((entry) => entry.manifest),
|
|
651
|
+
excluded: Excluded,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The zip as a stream. fflate writes into a TransformStream whose readable side is the response
|
|
656
|
+
* body; the pump pushes one entry at a time and waits for `writer.ready` between chunks, so the
|
|
657
|
+
* memory in flight stays one chunk deep however large an attachment is — the whole point of
|
|
658
|
+
* streaming rather than assembling (#136).
|
|
659
|
+
*/
|
|
660
|
+
function zipStream(manifest, entries) {
|
|
661
|
+
const { readable, writable } = new TransformStream();
|
|
662
|
+
const writer = writable.getWriter();
|
|
663
|
+
const zip = new Zip((error, chunk, final) => {
|
|
664
|
+
if (error) {
|
|
665
|
+
void writer.abort(error).catch(() => undefined);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
void writer.write(chunk).catch(() => undefined);
|
|
669
|
+
if (final)
|
|
670
|
+
void writer.close().catch(() => undefined);
|
|
671
|
+
});
|
|
672
|
+
const encoder = new TextEncoder();
|
|
673
|
+
async function pushText(name, text) {
|
|
674
|
+
// Text bodies deflate well and are bounded by the save limit; attachments go through the
|
|
675
|
+
// store path below instead, because recompressing archives and images buys nothing.
|
|
676
|
+
const file = new ZipDeflate(name, { level: 6 });
|
|
677
|
+
zip.add(file);
|
|
678
|
+
file.push(encoder.encode(text), true);
|
|
679
|
+
await writer.ready;
|
|
680
|
+
}
|
|
681
|
+
async function pump() {
|
|
682
|
+
// The manifest goes first so a streaming reader — the import — knows what it is walking into
|
|
683
|
+
// before the first content byte.
|
|
684
|
+
await pushText(BundleManifestFilename, JSON.stringify(manifest, null, 2));
|
|
685
|
+
for (const entry of entries) {
|
|
686
|
+
if (entry.content.type === "folder") {
|
|
687
|
+
const folder = new ZipPassThrough(entry.manifest.path);
|
|
688
|
+
zip.add(folder);
|
|
689
|
+
folder.push(new Uint8Array(0), true);
|
|
690
|
+
await writer.ready;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
// Nothing is written for it — not even an empty file, which is the whole point (#534). The
|
|
694
|
+
// manifest carries the node and says why its path is unoccupied.
|
|
695
|
+
if (entry.content.type === "absent")
|
|
696
|
+
continue;
|
|
697
|
+
if (entry.content.type === "text") {
|
|
698
|
+
await pushText(entry.manifest.path, await entry.content.load());
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
// ⚠️ No `null` branch here any more, and its removal is the repair (#560): it wrote a file
|
|
702
|
+
// of zero bytes for "the loader has nothing", which was true both for an attachment nobody
|
|
703
|
+
// ever uploaded to and for one whose object had left R2. The first is now `absent` above
|
|
704
|
+
// and never reaches this loop; the second throws out of `load()` and aborts the whole zip,
|
|
705
|
+
// which is what a backup that cannot be written is supposed to do.
|
|
706
|
+
//
|
|
707
|
+
// The loader runs BEFORE the entry is added, so a refusal leaves no half-opened file
|
|
708
|
+
// behind in the zip that nothing will ever terminate.
|
|
709
|
+
const stream = await entry.content.load();
|
|
710
|
+
const file = new ZipPassThrough(entry.manifest.path);
|
|
711
|
+
zip.add(file);
|
|
712
|
+
const reader = stream.getReader();
|
|
713
|
+
for (;;) {
|
|
714
|
+
const { done, value } = await reader.read();
|
|
715
|
+
if (done)
|
|
716
|
+
break;
|
|
717
|
+
file.push(value);
|
|
718
|
+
await writer.ready;
|
|
719
|
+
}
|
|
720
|
+
file.push(new Uint8Array(0), true);
|
|
721
|
+
await writer.ready;
|
|
722
|
+
}
|
|
723
|
+
zip.end();
|
|
724
|
+
}
|
|
725
|
+
pump().catch((error) => {
|
|
726
|
+
void writer.abort(error).catch(() => undefined);
|
|
727
|
+
});
|
|
728
|
+
return readable;
|
|
729
|
+
}
|
|
730
|
+
// The manifest, when the zip carries one. A file that merely wears the name — a naked folder
|
|
731
|
+
// with somebody's own manifest.json in it — is left to the naked path; a file that clearly
|
|
732
|
+
// means to be a bundle manifest but does not parse is a broken bundle and refuses the import.
|
|
733
|
+
function readImportManifest(files) {
|
|
734
|
+
const raw = files.get(BundleManifestFilename);
|
|
735
|
+
if (raw === undefined)
|
|
736
|
+
return { manifest: null, text: null };
|
|
737
|
+
let json;
|
|
738
|
+
let text;
|
|
739
|
+
try {
|
|
740
|
+
text = decodeText(raw, BundleManifestFilename);
|
|
741
|
+
json = JSON.parse(text);
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return { manifest: null, text: null };
|
|
745
|
+
}
|
|
746
|
+
if (!isRecord(json) || json.version === undefined || json.entries === undefined) {
|
|
747
|
+
return { manifest: null, text: null };
|
|
748
|
+
}
|
|
749
|
+
const parsed = BundleManifest.safeParse(json);
|
|
750
|
+
if (!parsed.success) {
|
|
751
|
+
throw new IntelError(400, "import_invalid_manifest", "manifest.json does not match the bundle manifest contract");
|
|
752
|
+
}
|
|
753
|
+
return { manifest: parsed.data, text };
|
|
754
|
+
}
|
|
755
|
+
// The plan a manifest dictates: kinds and titles from its entries, hierarchy from its paths.
|
|
756
|
+
// Sorted by path depth so a parent folder's new ID exists before its children ask for it — the
|
|
757
|
+
// same order the atomic insert needs (see `importTree`).
|
|
758
|
+
function importPlanFromManifest(manifest, files) {
|
|
759
|
+
const sorted = [...manifest.entries].sort((left, right) => depthOf(left.path) - depthOf(right.path));
|
|
760
|
+
const folderIdByPath = new Map();
|
|
761
|
+
const entries = [];
|
|
762
|
+
for (const entry of sorted) {
|
|
763
|
+
requireSafePath(entry.path);
|
|
764
|
+
const isFolder = entry.kind === "folder";
|
|
765
|
+
const parentPath = directoryOf(entry.path);
|
|
766
|
+
const parentNewId = parentPath === "" ? null : (folderIdByPath.get(parentPath) ?? null);
|
|
767
|
+
if (parentPath !== "" && parentNewId === null) {
|
|
768
|
+
throw new IntelError(400, "import_bundle_incomplete", `The manifest names no folder for: ${entry.path}`);
|
|
769
|
+
}
|
|
770
|
+
const newId = deps.id();
|
|
771
|
+
if (isFolder)
|
|
772
|
+
folderIdByPath.set(entry.path.endsWith("/") ? entry.path : `${entry.path}/`, newId);
|
|
773
|
+
let body = null;
|
|
774
|
+
// ⚠️ An entry that declares an `absence` names a path with nothing at it ON PURPOSE, so the
|
|
775
|
+
// zip is complete rather than broken (#534). Without this the export written by this very
|
|
776
|
+
// build would refuse its own import — and the node it describes arrives the way it left: a
|
|
777
|
+
// table with a title, a place in the tree and no content.
|
|
778
|
+
if (!isFolder && entry.absence === undefined) {
|
|
779
|
+
const found = files.get(entry.path);
|
|
780
|
+
if (found === undefined) {
|
|
781
|
+
throw new IntelError(400, "import_bundle_incomplete", `The manifest names a file the zip does not carry: ${entry.path}`);
|
|
782
|
+
}
|
|
783
|
+
body = found;
|
|
784
|
+
}
|
|
785
|
+
entries.push({
|
|
786
|
+
kind: entry.kind,
|
|
787
|
+
oldId: entry.id,
|
|
788
|
+
newId,
|
|
789
|
+
parentNewId,
|
|
790
|
+
title: entry.title,
|
|
791
|
+
description: entry.description,
|
|
792
|
+
mediaType: entry.mediaType,
|
|
793
|
+
body,
|
|
794
|
+
path: entry.path,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return entries;
|
|
798
|
+
}
|
|
799
|
+
// The plan a naked folder allows: hierarchy from the paths, kinds from the extensions (#137),
|
|
800
|
+
// no IDs and therefore nothing to remap. Directories a zip only implies — a file three levels
|
|
801
|
+
// deep without its folders spelled out — are created all the same.
|
|
802
|
+
function importPlanNaked(contents) {
|
|
803
|
+
const folderPaths = new Set();
|
|
804
|
+
const filePaths = [];
|
|
805
|
+
for (const directory of contents.directories) {
|
|
806
|
+
if (isJunkPath(directory))
|
|
807
|
+
continue;
|
|
808
|
+
requireSafePath(directory);
|
|
809
|
+
folderPaths.add(directory.endsWith("/") ? directory : `${directory}/`);
|
|
810
|
+
}
|
|
811
|
+
for (const path of contents.files.keys()) {
|
|
812
|
+
if (isJunkPath(path))
|
|
813
|
+
continue;
|
|
814
|
+
requireSafePath(path);
|
|
815
|
+
filePaths.push(path);
|
|
816
|
+
}
|
|
817
|
+
for (const path of [...folderPaths, ...filePaths]) {
|
|
818
|
+
const segments = path.replace(/\/$/, "").split("/");
|
|
819
|
+
for (let depth = 1; depth < segments.length; depth++) {
|
|
820
|
+
folderPaths.add(`${segments.slice(0, depth).join("/")}/`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const folderIdByPath = new Map();
|
|
824
|
+
const entries = [];
|
|
825
|
+
for (const path of [...folderPaths].sort((left, right) => depthOf(left) - depthOf(right))) {
|
|
826
|
+
const newId = deps.id();
|
|
827
|
+
folderIdByPath.set(path, newId);
|
|
828
|
+
const segments = path.replace(/\/$/, "").split("/");
|
|
829
|
+
entries.push({
|
|
830
|
+
kind: "folder",
|
|
831
|
+
oldId: null,
|
|
832
|
+
newId,
|
|
833
|
+
parentNewId: segments.length === 1
|
|
834
|
+
? null
|
|
835
|
+
: folderIdByPath.get(`${segments.slice(0, -1).join("/")}/`),
|
|
836
|
+
title: segments[segments.length - 1],
|
|
837
|
+
description: null,
|
|
838
|
+
mediaType: null,
|
|
839
|
+
body: null,
|
|
840
|
+
path,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
for (const path of filePaths.sort((left, right) => depthOf(left) - depthOf(right))) {
|
|
844
|
+
const segments = path.split("/");
|
|
845
|
+
const name = segments[segments.length - 1];
|
|
846
|
+
const derived = nakedKindOf(name);
|
|
847
|
+
entries.push({
|
|
848
|
+
kind: derived.kind,
|
|
849
|
+
oldId: null,
|
|
850
|
+
newId: deps.id(),
|
|
851
|
+
parentNewId: segments.length === 1
|
|
852
|
+
? null
|
|
853
|
+
: folderIdByPath.get(`${segments.slice(0, -1).join("/")}/`),
|
|
854
|
+
title: derived.title.length > 0 ? derived.title.slice(0, 240) : name.slice(0, 240),
|
|
855
|
+
description: null,
|
|
856
|
+
mediaType: derived.mediaType,
|
|
857
|
+
body: contents.files.get(path) ?? null,
|
|
858
|
+
path,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return entries;
|
|
862
|
+
}
|
|
863
|
+
function replayResult(metadata) {
|
|
864
|
+
return BundleImportResult.parse({
|
|
865
|
+
nodes: typeof metadata.nodes === "number" ? metadata.nodes : 0,
|
|
866
|
+
flows: typeof metadata.flows === "number" ? metadata.flows : 0,
|
|
867
|
+
rootNodeIds: Array.isArray(metadata.rootNodeIds) ? metadata.rootNodeIds : [],
|
|
868
|
+
replayed: true,
|
|
869
|
+
// Not read out of the stored metadata: a replay answers for an import this build performed,
|
|
870
|
+
// and the list is what this build leaves behind rather than something an earlier row said.
|
|
871
|
+
excluded: Excluded,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
async exportSubtree(actor, rootId) {
|
|
876
|
+
const entries = await plan(actor, rootId);
|
|
877
|
+
const manifest = manifestOf(rootId, entries);
|
|
878
|
+
const rootEntry = rootId === null ? null : entries.find((entry) => entry.manifest.id === rootId);
|
|
879
|
+
const filename = rootEntry === null || rootEntry === undefined
|
|
880
|
+
? "intel-export.zip"
|
|
881
|
+
: `${sanitizeName(rootEntry.manifest.title)}.zip`;
|
|
882
|
+
return { filename, stream: zipStream(manifest, entries) };
|
|
883
|
+
},
|
|
884
|
+
async exportFlow(actor, flowId) {
|
|
885
|
+
const flow = await deps.flows.getVisible(asFlowActor(actor), flowId);
|
|
886
|
+
if (!flow || flow.archivedAt !== null) {
|
|
887
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
888
|
+
}
|
|
889
|
+
const versionId = exportedVersionId(flow);
|
|
890
|
+
const version = versionId === null ? undefined : (await deps.flows.getVersions([versionId]))[0];
|
|
891
|
+
const entries = [plannedFlow(flow, version, "", new Set())];
|
|
892
|
+
// A single flow's bundle is rooted at the flow itself: the manifest names it as the root the
|
|
893
|
+
// same way a single node export does.
|
|
894
|
+
const manifest = manifestOf(flow.id, entries);
|
|
895
|
+
return { filename: `${sanitizeName(flow.title)}.zip`, stream: zipStream(manifest, entries) };
|
|
896
|
+
},
|
|
897
|
+
async manifest(actor, rootId) {
|
|
898
|
+
return manifestOf(rootId, await plan(actor, rootId));
|
|
899
|
+
},
|
|
900
|
+
/**
|
|
901
|
+
* One bundle in, one new subtree out (#137, phase 1): always new nodes, never a merge, never
|
|
902
|
+
* an overwrite — a second import of the same bundle is a second subtree, and only the
|
|
903
|
+
* idempotency key makes a RETRY of the same request not be one.
|
|
904
|
+
*
|
|
905
|
+
* The order of operations is the whole safety story: authorize, parse and decide everything,
|
|
906
|
+
* write the R2 bodies, then land every row in ONE `db.batch`. The batch is a transaction, so a
|
|
907
|
+
* half-imported tree cannot exist; R2 objects written for a batch that refused are deleted
|
|
908
|
+
* again, exactly as `save` treats its single object.
|
|
909
|
+
*/
|
|
910
|
+
async importBundle(actor, input) {
|
|
911
|
+
const earlier = await deps.repository.findImportReplay(actor.id, input.idempotencyKey);
|
|
912
|
+
if (earlier !== null)
|
|
913
|
+
return replayResult(earlier);
|
|
914
|
+
if (input.targetNodeId !== null) {
|
|
915
|
+
const target = await deps.repository.getVisible(actor, input.targetNodeId);
|
|
916
|
+
if (!target || target.archivedAt !== null) {
|
|
917
|
+
throw new IntelError(404, "node_not_found", "Node was not found");
|
|
918
|
+
}
|
|
919
|
+
if (target.kind !== "folder") {
|
|
920
|
+
throw new IntelError(409, "import_target_not_folder", "Imports land in a folder");
|
|
921
|
+
}
|
|
922
|
+
if (!(await deps.repository.can(actor, target.id, "write"))) {
|
|
923
|
+
throw new IntelError(403, "node_forbidden", "Target folder cannot be edited");
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const contents = await readZip(input.zip);
|
|
927
|
+
const { manifest, text: manifestText } = readImportManifest(contents.files);
|
|
928
|
+
if (manifest !== null)
|
|
929
|
+
contents.files.delete(BundleManifestFilename);
|
|
930
|
+
const entries = manifest !== null
|
|
931
|
+
? importPlanFromManifest(manifest, contents.files)
|
|
932
|
+
: importPlanNaked(contents);
|
|
933
|
+
if (entries.length === 0) {
|
|
934
|
+
throw new IntelError(400, "import_invalid_bundle", "The bundle holds nothing to import");
|
|
935
|
+
}
|
|
936
|
+
if (entries.some((entry) => entry.kind === "flow") && actor.canCreateFlows !== true) {
|
|
937
|
+
throw new IntelError(403, "permission_required", "Importing a bundle that carries flows needs the flows/create permission");
|
|
938
|
+
}
|
|
939
|
+
const idMap = new Map();
|
|
940
|
+
for (const entry of entries) {
|
|
941
|
+
if (entry.oldId !== null)
|
|
942
|
+
idMap.set(entry.oldId, entry.newId);
|
|
943
|
+
}
|
|
944
|
+
const occurredAt = deps.now().toISOString();
|
|
945
|
+
const nodes = [];
|
|
946
|
+
const versions = [];
|
|
947
|
+
const flows = [];
|
|
948
|
+
const flowVersions = [];
|
|
949
|
+
// Text bodies and media types per new node, kept for the link pass below.
|
|
950
|
+
const documentBodies = new Map();
|
|
951
|
+
const pendingWrites = [];
|
|
952
|
+
function versionRowFor(entry, content, mediaType, contentHash, segment) {
|
|
953
|
+
const versionId = deps.id();
|
|
954
|
+
const contentKey = contentKeyFor(entry.newId, versionId);
|
|
955
|
+
pendingWrites.push({ key: contentKey, mediaType, body: content });
|
|
956
|
+
return {
|
|
957
|
+
id: versionId,
|
|
958
|
+
nodeId: entry.newId,
|
|
959
|
+
sequence: 1,
|
|
960
|
+
contentKey,
|
|
961
|
+
mediaType,
|
|
962
|
+
contentHash,
|
|
963
|
+
size: typeof content === "string"
|
|
964
|
+
? new TextEncoder().encode(content).byteLength
|
|
965
|
+
: content.byteLength,
|
|
966
|
+
segment,
|
|
967
|
+
createdBy: actor.id,
|
|
968
|
+
createdAt: occurredAt,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
for (const entry of entries) {
|
|
972
|
+
const parentId = entry.parentNewId ?? input.targetNodeId;
|
|
973
|
+
if (entry.kind === "flow") {
|
|
974
|
+
const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
|
|
975
|
+
let versionId = null;
|
|
976
|
+
if (text.trim().length > 0) {
|
|
977
|
+
let graph;
|
|
978
|
+
try {
|
|
979
|
+
graph = remapFlowGraph(FlowGraph.parse(JSON.parse(text)), idMap);
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
throw new IntelError(400, "import_invalid_flow", `Bundle entry is not a flow graph: ${entry.path}`);
|
|
983
|
+
}
|
|
984
|
+
versionId = deps.id();
|
|
985
|
+
flowVersions.push({
|
|
986
|
+
id: versionId,
|
|
987
|
+
flowId: entry.newId,
|
|
988
|
+
sequence: 1,
|
|
989
|
+
graph,
|
|
990
|
+
createdBy: actor.id,
|
|
991
|
+
createdAt: occurredAt,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
flows.push({
|
|
995
|
+
/**
|
|
996
|
+
* ⚠️ **An import never carries a slash command over, and that is not a gap** (#775). A
|
|
997
|
+
* prompt name is unique across the whole installation the bundle lands IN, and the
|
|
998
|
+
* bundle knows nothing about it: importing a flow offered as `/billing` into a tree
|
|
999
|
+
* that already has one would fail the whole import over a name nobody chose here. The
|
|
1000
|
+
* import brings the content; offering it is a decision somebody makes afterwards, in
|
|
1001
|
+
* the installation that has to live with the name.
|
|
1002
|
+
*/
|
|
1003
|
+
promptName: null,
|
|
1004
|
+
id: entry.newId,
|
|
1005
|
+
parentId,
|
|
1006
|
+
title: plainTitle(entry.title),
|
|
1007
|
+
description: entry.description,
|
|
1008
|
+
ownerId: actor.id,
|
|
1009
|
+
currentVersionId: versionId,
|
|
1010
|
+
// Publishing froze a decision in the OLD installation; the import hands over a draft
|
|
1011
|
+
// and whoever owns the new tree publishes it anew.
|
|
1012
|
+
publishedVersionId: null,
|
|
1013
|
+
createdAt: occurredAt,
|
|
1014
|
+
updatedAt: occurredAt,
|
|
1015
|
+
archivedAt: null,
|
|
1016
|
+
});
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
let version = null;
|
|
1020
|
+
if (entry.kind === "document") {
|
|
1021
|
+
let text = entry.body === null ? "" : decodeText(entry.body, entry.path);
|
|
1022
|
+
const mediaType = entry.mediaType ?? "text/markdown";
|
|
1023
|
+
if (text.length > 0 && mediaType === BlockNoteMediaType) {
|
|
1024
|
+
text = remapDocumentLinks(text, idMap);
|
|
1025
|
+
}
|
|
1026
|
+
if (text.length > 0) {
|
|
1027
|
+
version = versionRowFor(entry, text, mediaType, await deps.hash(text), null);
|
|
1028
|
+
documentBodies.set(entry.newId, { mediaType, content: text });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else if (entry.kind === "table") {
|
|
1032
|
+
const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
|
|
1033
|
+
if (text.trim().length > 0) {
|
|
1034
|
+
const header = parseCsv(text)[0] ?? [];
|
|
1035
|
+
if (header.length === 0 || header.every((column) => column.trim().length === 0)) {
|
|
1036
|
+
throw new IntelError(400, "import_invalid_table", `Bundle entry is not a table with a header row: ${entry.path}`);
|
|
1037
|
+
}
|
|
1038
|
+
// ⚠️ Distinct column names, read the same way `DefineTableInput` and
|
|
1039
|
+
// `RedefineTableInput` read them — case-insensitively (#322). Both refuse a duplicate,
|
|
1040
|
+
// and the import used to walk past both: a table could only enter the tree this way
|
|
1041
|
+
// with two columns of one name.
|
|
1042
|
+
//
|
|
1043
|
+
// The consumer assumes the opposite and says so. `packages/intel/ui/src/node-table` keys its
|
|
1044
|
+
// header cells and every row cell with the column name and carries the comment
|
|
1045
|
+
// "Column names are distinct by contract (`DefineTableInput`)" — true for the two write
|
|
1046
|
+
// paths, and false for this one. Two equal names are two React keys with one value, in
|
|
1047
|
+
// the header AND in every row.
|
|
1048
|
+
const seen = new Set(header.map((column) => column.trim().toLowerCase()));
|
|
1049
|
+
if (seen.size !== header.length) {
|
|
1050
|
+
throw new IntelError(400, "import_invalid_table", `Bundle entry has two columns of the same name: ${entry.path}`);
|
|
1051
|
+
}
|
|
1052
|
+
// The whole file as one snapshot: the same shape a definition or a redefine writes
|
|
1053
|
+
// (#135), so reading starts here and nothing older is expected to exist.
|
|
1054
|
+
version = versionRowFor(entry, text, TableMediaType, await deps.hash(text), "snapshot");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
else if (entry.kind === "board" || entry.kind === "agent") {
|
|
1058
|
+
/**
|
|
1059
|
+
* ⚠️ Refused by name, and the name says WHY rather than what the parser tripped over.
|
|
1060
|
+
*
|
|
1061
|
+
* Agents and Boards were parked with #385; nothing in this build can hold either kind.
|
|
1062
|
+
* A bundle that carries one is not malformed — it is a correct export of an installation
|
|
1063
|
+
* that still had the feature, and the person holding it is usually moving a tree between
|
|
1064
|
+
* two installations rather than the author of the file. Left to the schema they would get
|
|
1065
|
+
* `unexpected enum value`, look for a broken file, and find nothing wrong with it.
|
|
1066
|
+
*
|
|
1067
|
+
* The refusal names the entry, the kind and the branch the feature is parked on, so the
|
|
1068
|
+
* answer to "where did my board go" is in the message and not in somebody's memory.
|
|
1069
|
+
*
|
|
1070
|
+
* ⚠️ Only a MANIFEST can reach this, because only a manifest declares a kind. A naked
|
|
1071
|
+
* folder holding a `plan.board.json` imports it as an attachment — see `kindOfFile`.
|
|
1072
|
+
*/
|
|
1073
|
+
throw new IntelError(400, "import_kind_parked", `Bundle entry “${entry.path}” is a ${entry.kind}, and ${entry.kind}s are not part of this installation any more (anchrd/intel#385). The bundle was exported from a version that still had them; the code is on the branch parked/agents-and-board. Remove the entry from the bundle to import the rest.`);
|
|
1074
|
+
}
|
|
1075
|
+
else if (entry.kind === "attachment" && entry.body !== null) {
|
|
1076
|
+
// ⚠️ `entry.body === null` reaches here only for a manifest entry that declared an
|
|
1077
|
+
// absence — a naked folder always has the bytes of the file it names. It used to be read
|
|
1078
|
+
// as `new Uint8Array(0)` and given a version of zero bytes, which is a DIFFERENT node
|
|
1079
|
+
// from the one that was exported: the export said "never uploaded to", and the import
|
|
1080
|
+
// would answer with an upload of nothing. No version is what came out of that
|
|
1081
|
+
// installation and no version is what goes back in (#560).
|
|
1082
|
+
version = versionRowFor(entry, entry.body, entry.mediaType ?? "application/octet-stream", await deps.hash(entry.body), null);
|
|
1083
|
+
}
|
|
1084
|
+
if (version !== null)
|
|
1085
|
+
versions.push(version);
|
|
1086
|
+
nodes.push({
|
|
1087
|
+
// Not carried over, for the reason the flow half above spells out: the catalogue belongs
|
|
1088
|
+
// to the installation, not to the bundle (#775).
|
|
1089
|
+
promptName: null,
|
|
1090
|
+
id: entry.newId,
|
|
1091
|
+
parentId,
|
|
1092
|
+
// Flows took the `continue` above; what reaches here is one of the six node kinds.
|
|
1093
|
+
kind: entry.kind,
|
|
1094
|
+
// A manifest title and a file name are both somebody else's text, and an export written
|
|
1095
|
+
// by an escaping chain carries the entity in both (#202).
|
|
1096
|
+
title: plainTitle(entry.title),
|
|
1097
|
+
description: entry.description,
|
|
1098
|
+
ownerId: actor.id,
|
|
1099
|
+
currentVersionId: version?.id ?? null,
|
|
1100
|
+
createdAt: occurredAt,
|
|
1101
|
+
updatedAt: occurredAt,
|
|
1102
|
+
archivedAt: null,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
// The link pass: what the imported texts now point at. Targets inside the import are linked
|
|
1106
|
+
// as written — they exist by the same transaction. Targets outside go through the same
|
|
1107
|
+
// visibility question a save asks, so an import cannot mint a link into a part of the tree
|
|
1108
|
+
// its author may not see.
|
|
1109
|
+
const links = [];
|
|
1110
|
+
const importedIds = new Set(nodes.map((node) => node.id));
|
|
1111
|
+
const outsideTargets = new Set();
|
|
1112
|
+
for (const [sourceId, body] of documentBodies) {
|
|
1113
|
+
for (const target of documentLinkTargets(body.mediaType, body.content)) {
|
|
1114
|
+
if (target === sourceId)
|
|
1115
|
+
continue;
|
|
1116
|
+
if (!importedIds.has(target))
|
|
1117
|
+
outsideTargets.add(target);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const visibleOutside = new Set(outsideTargets.size === 0
|
|
1121
|
+
? []
|
|
1122
|
+
: (await deps.repository.resolveVisibleTitles(actor, [...outsideTargets])).map((row) => row.nodeId));
|
|
1123
|
+
for (const [sourceId, body] of documentBodies) {
|
|
1124
|
+
for (const target of documentLinkTargets(body.mediaType, body.content)) {
|
|
1125
|
+
if (target === sourceId)
|
|
1126
|
+
continue;
|
|
1127
|
+
if (importedIds.has(target) || visibleOutside.has(target)) {
|
|
1128
|
+
links.push({ id: deps.id(), sourceNodeId: sourceId, targetNodeId: target });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const rootNodeIds = nodes
|
|
1133
|
+
.filter((node) => node.parentId === input.targetNodeId)
|
|
1134
|
+
.map((node) => node.id);
|
|
1135
|
+
const metadata = {
|
|
1136
|
+
manifestHash: await deps.hash(manifestText ?? [...contents.files.keys()].sort().join("\n")),
|
|
1137
|
+
nodes: nodes.length,
|
|
1138
|
+
flows: flows.length,
|
|
1139
|
+
rootNodeIds,
|
|
1140
|
+
};
|
|
1141
|
+
// R2 first, rows second, cleanup on refusal — the same discipline every single-version
|
|
1142
|
+
// write in `nodes.ts` follows, over many objects at once.
|
|
1143
|
+
const written = [];
|
|
1144
|
+
try {
|
|
1145
|
+
for (const write of pendingWrites) {
|
|
1146
|
+
if (typeof write.body === "string") {
|
|
1147
|
+
await deps.content.put(write.key, write.body, write.mediaType);
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
const copy = new Uint8Array(write.body.byteLength);
|
|
1151
|
+
copy.set(write.body);
|
|
1152
|
+
await deps.content.putBytes(write.key, copy.buffer, write.mediaType);
|
|
1153
|
+
}
|
|
1154
|
+
written.push(write.key);
|
|
1155
|
+
}
|
|
1156
|
+
const outcome = await deps.repository.importTree({
|
|
1157
|
+
nodes,
|
|
1158
|
+
versions,
|
|
1159
|
+
links,
|
|
1160
|
+
flows,
|
|
1161
|
+
flowVersions,
|
|
1162
|
+
actorId: actor.id,
|
|
1163
|
+
idempotencyKey: input.idempotencyKey,
|
|
1164
|
+
auditId: deps.id(),
|
|
1165
|
+
auditResourceId: input.targetNodeId ?? "root",
|
|
1166
|
+
metadata,
|
|
1167
|
+
occurredAt,
|
|
1168
|
+
});
|
|
1169
|
+
if (outcome === "replayed") {
|
|
1170
|
+
// A racing retry landed first; these objects belong to nobody's rows.
|
|
1171
|
+
await Promise.all(written.map(async (key) => await deps.content.delete(key).catch(() => undefined)));
|
|
1172
|
+
const stored = await deps.repository.findImportReplay(actor.id, input.idempotencyKey);
|
|
1173
|
+
return replayResult(stored ?? {});
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
await Promise.all(written.map(async (key) => await deps.content.delete(key).catch(() => undefined)));
|
|
1178
|
+
throw error;
|
|
1179
|
+
}
|
|
1180
|
+
// Derived indexes only after the canonical write stands (#137): the queue reads D1 and R2,
|
|
1181
|
+
// and both now hold what it will find.
|
|
1182
|
+
for (const version of versions) {
|
|
1183
|
+
await deps.indexing.enqueue(version.id);
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
nodes: nodes.length,
|
|
1187
|
+
flows: flows.length,
|
|
1188
|
+
rootNodeIds,
|
|
1189
|
+
replayed: false,
|
|
1190
|
+
excluded: Excluded,
|
|
1191
|
+
};
|
|
1192
|
+
},
|
|
1193
|
+
};
|
|
1194
|
+
}
|