@agent-native/core 0.102.2 → 0.103.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 (205) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/local-file-mode.mdx +99 -489
  4. package/corpus/core/docs/content/locales/ar-SA/local-file-mode.mdx +35 -490
  5. package/corpus/core/docs/content/locales/ar-SA/template-content.mdx +10 -24
  6. package/corpus/core/docs/content/locales/de-DE/local-file-mode.mdx +37 -491
  7. package/corpus/core/docs/content/locales/de-DE/template-content.mdx +10 -24
  8. package/corpus/core/docs/content/locales/es-ES/local-file-mode.mdx +35 -493
  9. package/corpus/core/docs/content/locales/es-ES/template-content.mdx +10 -24
  10. package/corpus/core/docs/content/locales/fr-FR/local-file-mode.mdx +38 -491
  11. package/corpus/core/docs/content/locales/fr-FR/template-content.mdx +10 -24
  12. package/corpus/core/docs/content/locales/hi-IN/local-file-mode.mdx +33 -492
  13. package/corpus/core/docs/content/locales/hi-IN/template-content.mdx +10 -24
  14. package/corpus/core/docs/content/locales/ja-JP/local-file-mode.mdx +36 -492
  15. package/corpus/core/docs/content/locales/ja-JP/template-content.mdx +10 -24
  16. package/corpus/core/docs/content/locales/ko-KR/local-file-mode.mdx +35 -490
  17. package/corpus/core/docs/content/locales/ko-KR/template-content.mdx +10 -24
  18. package/corpus/core/docs/content/locales/pt-BR/local-file-mode.mdx +35 -493
  19. package/corpus/core/docs/content/locales/pt-BR/template-content.mdx +10 -24
  20. package/corpus/core/docs/content/locales/zh-CN/local-file-mode.mdx +31 -489
  21. package/corpus/core/docs/content/locales/zh-CN/template-content.mdx +9 -24
  22. package/corpus/core/docs/content/locales/zh-TW/local-file-mode.mdx +31 -489
  23. package/corpus/core/docs/content/locales/zh-TW/template-content.mdx +9 -24
  24. package/corpus/core/docs/content/template-content.mdx +26 -42
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/cli/content-local.ts +86 -18
  27. package/corpus/core/src/cli/index.ts +2 -2
  28. package/corpus/core/src/cli/skills-content/content-skill.ts +36 -25
  29. package/corpus/core/src/cli/skills.ts +47 -13
  30. package/corpus/core/src/local-artifacts/index.ts +65 -0
  31. package/corpus/core/src/vite/client.ts +2 -0
  32. package/corpus/templates/assets/app/components/library/GenerationPresetsPanel.tsx +325 -0
  33. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +12 -0
  34. package/corpus/templates/assets/app/i18n/zh-TW.ts +21 -0
  35. package/corpus/templates/assets/app/i18n-data.ts +215 -0
  36. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +19 -479
  37. package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +1 -1
  38. package/corpus/templates/assets/app/routes/brand-kits.$id_.settings.tsx +446 -0
  39. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +12 -2
  40. package/corpus/templates/clips/app/components/player/settings-panel.tsx +30 -12
  41. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -1
  42. package/corpus/templates/clips/app/components/recorder/microphone-visualizer.tsx +22 -3
  43. package/corpus/templates/clips/app/i18n/ar-SA.ts +3 -2
  44. package/corpus/templates/clips/app/i18n/de-DE.ts +4 -2
  45. package/corpus/templates/clips/app/i18n/en-US.ts +3 -2
  46. package/corpus/templates/clips/app/i18n/es-ES.ts +4 -2
  47. package/corpus/templates/clips/app/i18n/fr-FR.ts +3 -2
  48. package/corpus/templates/clips/app/i18n/hi-IN.ts +3 -2
  49. package/corpus/templates/clips/app/i18n/ja-JP.ts +4 -1
  50. package/corpus/templates/clips/app/i18n/ko-KR.ts +3 -2
  51. package/corpus/templates/clips/app/i18n/pt-BR.ts +4 -2
  52. package/corpus/templates/clips/app/i18n/zh-CN.ts +3 -2
  53. package/corpus/templates/clips/app/i18n/zh-TW.ts +3 -2
  54. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +27 -1
  55. package/corpus/templates/clips/server/lib/share-password.ts +6 -3
  56. package/corpus/templates/content/.agents/skills/content/SKILL.md +26 -39
  57. package/corpus/templates/content/AGENTS.md +25 -30
  58. package/corpus/templates/content/actions/_builder-docs-client.ts +23 -0
  59. package/corpus/templates/content/actions/_content-database-source-adapters.ts +21 -3
  60. package/corpus/templates/content/actions/_content-files.ts +338 -0
  61. package/corpus/templates/content/actions/_content-space-access.ts +135 -0
  62. package/corpus/templates/content/actions/_content-spaces.ts +640 -0
  63. package/corpus/templates/content/actions/_database-source-utils.ts +90 -7
  64. package/corpus/templates/content/actions/_database-utils.ts +7 -1
  65. package/corpus/templates/content/actions/_local-file-documents.ts +0 -9
  66. package/corpus/templates/content/actions/_local-folder-source.ts +53 -0
  67. package/corpus/templates/content/actions/_property-utils.ts +9 -4
  68. package/corpus/templates/content/actions/add-database-item.ts +108 -65
  69. package/corpus/templates/content/actions/backfill-content-files.ts +22 -0
  70. package/corpus/templates/content/actions/connect-local-folder-source.ts +221 -0
  71. package/corpus/templates/content/actions/create-content-database.ts +129 -3
  72. package/corpus/templates/content/actions/create-document.ts +65 -55
  73. package/corpus/templates/content/actions/create-inline-content-database.ts +6 -1
  74. package/corpus/templates/content/actions/delete-content-database.ts +3 -0
  75. package/corpus/templates/content/actions/delete-document.ts +7 -11
  76. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +175 -0
  77. package/corpus/templates/content/actions/duplicate-database-item.ts +10 -0
  78. package/corpus/templates/content/actions/duplicate-database-items.ts +13 -0
  79. package/corpus/templates/content/actions/edit-document.ts +2 -28
  80. package/corpus/templates/content/actions/ensure-content-spaces.ts +26 -0
  81. package/corpus/templates/content/actions/export-content-source.ts +47 -9
  82. package/corpus/templates/content/actions/get-document.ts +0 -14
  83. package/corpus/templates/content/actions/import-content-source.ts +43 -3
  84. package/corpus/templates/content/actions/list-content-spaces.ts +103 -0
  85. package/corpus/templates/content/actions/list-documents.ts +12 -9
  86. package/corpus/templates/content/actions/move-document.ts +3 -14
  87. package/corpus/templates/content/actions/pull-document.ts +0 -23
  88. package/corpus/templates/content/actions/remove-local-file-source.ts +50 -29
  89. package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +442 -0
  90. package/corpus/templates/content/actions/reveal-local-source-file.ts +6 -17
  91. package/corpus/templates/content/actions/search-documents.ts +0 -34
  92. package/corpus/templates/content/actions/share-local-file-document.ts +25 -1
  93. package/corpus/templates/content/actions/submit-content-database-form.ts +12 -0
  94. package/corpus/templates/content/actions/sync-local-folder-source.ts +731 -0
  95. package/corpus/templates/content/actions/sync-manifest-local-folder-source.ts +123 -0
  96. package/corpus/templates/content/actions/update-content-database-view.ts +10 -1
  97. package/corpus/templates/content/actions/update-document.ts +0 -15
  98. package/corpus/templates/content/actions/view-screen.ts +2 -50
  99. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +76 -62
  100. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +70 -22
  101. package/corpus/templates/content/app/components/editor/database/grouping.ts +2 -1
  102. package/corpus/templates/content/app/components/editor/database/settings.tsx +23 -20
  103. package/corpus/templates/content/app/components/editor/database/shared.tsx +2 -0
  104. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +330 -0
  105. package/corpus/templates/content/app/components/editor/database/types.ts +1 -0
  106. package/corpus/templates/content/app/components/editor/database/view-config.ts +3 -1
  107. package/corpus/templates/content/app/components/layout/Layout.tsx +25 -1
  108. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +209 -113
  109. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +31 -0
  110. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -0
  111. package/corpus/templates/content/app/hooks/use-content-spaces.ts +39 -0
  112. package/corpus/templates/content/app/i18n/zh-TW.ts +11 -0
  113. package/corpus/templates/content/app/i18n-data.ts +75 -0
  114. package/corpus/templates/content/app/routes/_app.local-files.tsx +174 -65
  115. package/corpus/templates/content/changelog/2026-07-16-content-callouts-can-now-be-reviewed-and-published-to-builder.md +6 -0
  116. package/corpus/templates/content/changelog/2026-07-16-heading-5-and-heading-6-are-available-from-the-editor-slash-.md +6 -0
  117. package/corpus/templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md +6 -0
  118. package/corpus/templates/content/parity/matrix.md +2 -1
  119. package/corpus/templates/content/parity/matrix.ts +37 -1
  120. package/corpus/templates/content/scripts/dev-database.mjs +7 -5
  121. package/corpus/templates/content/server/db/schema.ts +113 -40
  122. package/corpus/templates/content/server/lib/document-context.ts +15 -1
  123. package/corpus/templates/content/server/lib/notion-sync.ts +6 -0
  124. package/corpus/templates/content/server/plugins/db.ts +153 -0
  125. package/corpus/templates/content/shared/api.ts +17 -3
  126. package/corpus/templates/content/shared/builder-mdx.ts +163 -16
  127. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +59 -0
  128. package/corpus/templates/design/AGENTS.md +6 -1
  129. package/corpus/templates/design/actions/create-design-from-template.ts +19 -7
  130. package/corpus/templates/design/actions/list-design-templates.ts +69 -21
  131. package/corpus/templates/design/actions/navigate.ts +2 -2
  132. package/corpus/templates/design/actions/view-screen.ts +15 -2
  133. package/corpus/templates/design/agent-native.app-skill.json +5 -0
  134. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +281 -56
  135. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +14 -2
  136. package/corpus/templates/design/app/hooks/use-navigation-state.ts +3 -2
  137. package/corpus/templates/design/app/i18n/zh-TW.ts +3 -3
  138. package/corpus/templates/design/app/i18n-data.ts +21 -2
  139. package/corpus/templates/design/app/i18n-template-feature.ts +218 -10
  140. package/corpus/templates/design/app/pages/Index.tsx +195 -24
  141. package/corpus/templates/design/app/pages/Templates.tsx +144 -55
  142. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +12 -1
  143. package/corpus/templates/design/changelog/2026-07-14-templates-now-appear-in-the-new-design-picker-with-previews-.md +6 -0
  144. package/corpus/templates/design/changelog/2026-07-15-templates-now-open-immediately-after-copying-even-while-the-.md +6 -0
  145. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  146. package/corpus/templates/mail/AGENTS.md +23 -18
  147. package/corpus/templates/mail/actions/bulk-archive.ts +32 -28
  148. package/corpus/templates/mail/actions/mark-read.ts +131 -20
  149. package/corpus/templates/mail/actions/mark-thread-read.ts +1 -1
  150. package/corpus/templates/mail/actions/move-email.ts +30 -24
  151. package/corpus/templates/mail/actions/send-email.ts +73 -68
  152. package/corpus/templates/mail/changelog/2026-07-15-unread-inbox-cleanup-now-completes-reliably.md +6 -0
  153. package/corpus/templates/mail/server/handlers/emails.ts +218 -209
  154. package/corpus/templates/mail/server/lib/bulk-mark-read.ts +30 -0
  155. package/corpus/templates/mail/server/lib/email-state.ts +198 -113
  156. package/corpus/templates/mail/server/lib/google-auth.ts +218 -25
  157. package/corpus/templates/mail/server/lib/jobs.ts +69 -74
  158. package/corpus/templates/mail/server/lib/local-email-store.ts +265 -0
  159. package/corpus/templates/mail/server/plugins/agent-chat.ts +6 -0
  160. package/dist/cli/content-local.d.ts +1 -0
  161. package/dist/cli/content-local.d.ts.map +1 -1
  162. package/dist/cli/content-local.js +75 -17
  163. package/dist/cli/content-local.js.map +1 -1
  164. package/dist/cli/index.js +2 -2
  165. package/dist/cli/index.js.map +1 -1
  166. package/dist/cli/skills-content/content-skill.d.ts +1 -1
  167. package/dist/cli/skills-content/content-skill.d.ts.map +1 -1
  168. package/dist/cli/skills-content/content-skill.js +36 -25
  169. package/dist/cli/skills-content/content-skill.js.map +1 -1
  170. package/dist/cli/skills.d.ts.map +1 -1
  171. package/dist/cli/skills.js +47 -14
  172. package/dist/cli/skills.js.map +1 -1
  173. package/dist/collab/struct-routes.d.ts +1 -1
  174. package/dist/local-artifacts/index.d.ts +10 -0
  175. package/dist/local-artifacts/index.d.ts.map +1 -1
  176. package/dist/local-artifacts/index.js +39 -0
  177. package/dist/local-artifacts/index.js.map +1 -1
  178. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  179. package/dist/vite/client.d.ts.map +1 -1
  180. package/dist/vite/client.js +2 -0
  181. package/dist/vite/client.js.map +1 -1
  182. package/docs/content/local-file-mode.mdx +99 -489
  183. package/docs/content/locales/ar-SA/local-file-mode.mdx +35 -490
  184. package/docs/content/locales/ar-SA/template-content.mdx +10 -24
  185. package/docs/content/locales/de-DE/local-file-mode.mdx +37 -491
  186. package/docs/content/locales/de-DE/template-content.mdx +10 -24
  187. package/docs/content/locales/es-ES/local-file-mode.mdx +35 -493
  188. package/docs/content/locales/es-ES/template-content.mdx +10 -24
  189. package/docs/content/locales/fr-FR/local-file-mode.mdx +38 -491
  190. package/docs/content/locales/fr-FR/template-content.mdx +10 -24
  191. package/docs/content/locales/hi-IN/local-file-mode.mdx +33 -492
  192. package/docs/content/locales/hi-IN/template-content.mdx +10 -24
  193. package/docs/content/locales/ja-JP/local-file-mode.mdx +36 -492
  194. package/docs/content/locales/ja-JP/template-content.mdx +10 -24
  195. package/docs/content/locales/ko-KR/local-file-mode.mdx +35 -490
  196. package/docs/content/locales/ko-KR/template-content.mdx +10 -24
  197. package/docs/content/locales/pt-BR/local-file-mode.mdx +35 -493
  198. package/docs/content/locales/pt-BR/template-content.mdx +10 -24
  199. package/docs/content/locales/zh-CN/local-file-mode.mdx +31 -489
  200. package/docs/content/locales/zh-CN/template-content.mdx +9 -24
  201. package/docs/content/locales/zh-TW/local-file-mode.mdx +31 -489
  202. package/docs/content/locales/zh-TW/template-content.mdx +9 -24
  203. package/docs/content/template-content.mdx +26 -42
  204. package/package.json +1 -1
  205. package/corpus/templates/assets/app/components/library/EditLibraryDialog.tsx +0 -126
@@ -0,0 +1,731 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { defineAction } from "@agent-native/core";
4
+ import { writeAppState } from "@agent-native/core/application-state";
5
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
6
+ import { assertAccess } from "@agent-native/core/sharing";
7
+ import { and, eq, inArray } from "drizzle-orm";
8
+ import { z } from "zod";
9
+
10
+ import { getDb, schema } from "../server/db/index.js";
11
+ import type { ContentDatabaseSourceTruthPolicy } from "../shared/api.js";
12
+ import {
13
+ isBuilderMdxSourcePath,
14
+ isContentSourcePath,
15
+ parseContentSourceFile,
16
+ } from "../shared/content-source.js";
17
+ import { ensureDocumentsFilesMembership } from "./_content-files.js";
18
+ import { resolveContentSpaceAccess } from "./_content-space-access.js";
19
+ import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js";
20
+
21
+ const MAX_SOURCE_FILES = 500;
22
+ const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
23
+
24
+ function opaqueId(kind: string, value: string) {
25
+ return `${kind}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`;
26
+ }
27
+
28
+ function contentHash(content: string) {
29
+ return createHash("sha256").update(content).digest("hex");
30
+ }
31
+
32
+ function metadataHash(value: {
33
+ title: string;
34
+ description?: string | null;
35
+ icon?: string | null;
36
+ }) {
37
+ return contentHash(
38
+ JSON.stringify({
39
+ title: value.title,
40
+ description: value.description ?? "",
41
+ icon: value.icon ?? null,
42
+ }),
43
+ );
44
+ }
45
+
46
+ function parseJson(value: string | null | undefined) {
47
+ if (!value) return {} as Record<string, unknown>;
48
+ try {
49
+ const parsed = JSON.parse(value) as unknown;
50
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
51
+ ? (parsed as Record<string, unknown>)
52
+ : {};
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ function truthPolicy(value: unknown): ContentDatabaseSourceTruthPolicy {
59
+ return value === "source_primary" || value === "reviewed_bidirectional"
60
+ ? value
61
+ : "database_primary";
62
+ }
63
+
64
+ function normalizedEntries(files: Record<string, string>) {
65
+ return Object.entries(files)
66
+ .filter(([path]) => isContentSourcePath(path))
67
+ .sort(([left], [right]) => left.localeCompare(right));
68
+ }
69
+
70
+ function sourceValues(args: {
71
+ path: string;
72
+ title: string;
73
+ hash: string;
74
+ metadataHash: string;
75
+ }) {
76
+ return JSON.stringify({
77
+ relativePath: args.path,
78
+ extension: args.path.toLowerCase().endsWith(".mdx") ? ".mdx" : ".md",
79
+ title: args.title,
80
+ contentHash: args.hash,
81
+ metadataHash: args.metadataHash,
82
+ });
83
+ }
84
+
85
+ function bodyChangeJson(args: {
86
+ currentHash: string;
87
+ incomingHash: string;
88
+ currentMetadataHash: string;
89
+ incomingMetadataHash: string;
90
+ }) {
91
+ return JSON.stringify({
92
+ summary: "Local folder and Content both changed since the last sync.",
93
+ currentExcerpt: null,
94
+ proposedExcerpt: null,
95
+ currentHash: args.currentHash,
96
+ proposedHash: args.incomingHash,
97
+ currentMetadataHash: args.currentMetadataHash,
98
+ proposedMetadataHash: args.incomingMetadataHash,
99
+ });
100
+ }
101
+
102
+ function deletionChangeJson(args: {
103
+ path: string;
104
+ previousHash: string | null;
105
+ }) {
106
+ return JSON.stringify({
107
+ operation: "source_delete",
108
+ relativePath: args.path,
109
+ previousHash: args.previousHash,
110
+ });
111
+ }
112
+
113
+ function metadataChanges(
114
+ current: { title: string; description?: string | null; icon?: string | null },
115
+ proposed: {
116
+ title: string;
117
+ description?: string | null;
118
+ icon?: string | null;
119
+ },
120
+ ) {
121
+ return (["title", "description", "icon"] as const).flatMap((field) => {
122
+ const currentValue =
123
+ current[field] ?? (field === "description" ? "" : null);
124
+ const proposedValue =
125
+ proposed[field] ?? (field === "description" ? "" : null);
126
+ return currentValue === proposedValue
127
+ ? []
128
+ : [{ field, currentValue, proposedValue }];
129
+ });
130
+ }
131
+
132
+ export default defineAction({
133
+ description:
134
+ "Refresh a connected local-folder source through the trusted browser/Desktop bridge, materializing Markdown/MDX as normal SQL-backed Content pages and recording safe source identity and conflicts.",
135
+ schema: z.object({
136
+ sourceId: z.string().min(1),
137
+ files: z
138
+ .record(z.string(), z.string().max(MAX_SOURCE_FILE_BYTES))
139
+ .refine((value) => Object.keys(value).length <= MAX_SOURCE_FILES, {
140
+ message: `Sync is limited to ${MAX_SOURCE_FILES} files.`,
141
+ }),
142
+ dryRun: z.boolean().optional().default(false),
143
+ }),
144
+ run: async ({ sourceId, files, dryRun }) => {
145
+ const userEmail = getRequestUserEmail();
146
+ if (!userEmail) throw new Error("no authenticated user");
147
+ const builderPaths = Object.keys(files).filter(isBuilderMdxSourcePath);
148
+ if (builderPaths.length) {
149
+ throw new Error(
150
+ `Builder .builder.mdx files require the Builder actions: ${builderPaths.join(", ")}`,
151
+ );
152
+ }
153
+ const entries = normalizedEntries(files);
154
+ const db = getDb();
155
+ const [target] = await db
156
+ .select({
157
+ source: schema.contentDatabaseSources,
158
+ database: schema.contentDatabases,
159
+ })
160
+ .from(schema.contentDatabaseSources)
161
+ .innerJoin(
162
+ schema.contentDatabases,
163
+ eq(
164
+ schema.contentDatabases.id,
165
+ schema.contentDatabaseSources.databaseId,
166
+ ),
167
+ )
168
+ .where(eq(schema.contentDatabaseSources.id, sourceId));
169
+ if (
170
+ !target ||
171
+ target.source.sourceType !== LOCAL_FOLDER_SOURCE_TYPE ||
172
+ target.database.systemRole !== "files" ||
173
+ !target.database.spaceId
174
+ ) {
175
+ throw new Error(`Local folder source "${sourceId}" not found`);
176
+ }
177
+ await resolveContentSpaceAccess(target.database.spaceId, "editor");
178
+ const targetSpaceId = target.database.spaceId;
179
+
180
+ const parsed = entries.map(([path, value]) =>
181
+ parseContentSourceFile(path, value),
182
+ );
183
+ const parseErrors = parsed.flatMap((file) =>
184
+ file.errors?.length
185
+ ? [{ path: file.path, reason: file.errors.join(" ") }]
186
+ : [],
187
+ );
188
+ const valid = parsed.filter((file) => !file.errors?.length);
189
+ const duplicateIds = new Set<string>();
190
+ const seenIds = new Set<string>();
191
+ for (const file of valid) {
192
+ if (!file.id) continue;
193
+ if (seenIds.has(file.id)) duplicateIds.add(file.id);
194
+ seenIds.add(file.id);
195
+ }
196
+ if (duplicateIds.size) {
197
+ throw new Error(
198
+ `Duplicate source ids: ${[...duplicateIds].sort().join(", ")}`,
199
+ );
200
+ }
201
+
202
+ const explicitIds = valid.flatMap((file) => (file.id ? [file.id] : []));
203
+ type SourceRow = typeof schema.contentDatabaseSourceRows.$inferSelect;
204
+ type Document = typeof schema.documents.$inferSelect;
205
+ const loadSnapshot = async (queryDb: any) => {
206
+ const storedRows = (await queryDb
207
+ .select()
208
+ .from(schema.contentDatabaseSourceRows)
209
+ .where(
210
+ eq(schema.contentDatabaseSourceRows.sourceId, sourceId),
211
+ )) as SourceRow[];
212
+ const rowByPath = new Map(
213
+ storedRows.map((row) => [
214
+ String(parseJson(row.sourceValuesJson).relativePath ?? ""),
215
+ row,
216
+ ]),
217
+ );
218
+ const rowByDocumentId = new Map(
219
+ storedRows.map((row) => [row.documentId, row]),
220
+ );
221
+ const linkedIds = storedRows.map((row) => row.documentId);
222
+ const candidateIds = [...new Set([...explicitIds, ...linkedIds])];
223
+ const existingDocuments = (
224
+ candidateIds.length
225
+ ? await queryDb
226
+ .select()
227
+ .from(schema.documents)
228
+ .where(inArray(schema.documents.id, candidateIds))
229
+ : []
230
+ ) as Document[];
231
+ const documentById = new Map(
232
+ existingDocuments.map((document) => [document.id, document]),
233
+ );
234
+ return { storedRows, rowByPath, rowByDocumentId, documentById };
235
+ };
236
+
237
+ const initialSnapshot = await loadSnapshot(db);
238
+
239
+ const validateDocumentIds = async (
240
+ snapshot: Awaited<ReturnType<typeof loadSnapshot>>,
241
+ ) => {
242
+ const documentIds = new Set([
243
+ ...explicitIds,
244
+ ...snapshot.storedRows.map((row) => row.documentId),
245
+ ]);
246
+ for (const id of documentIds) {
247
+ const existing = snapshot.documentById.get(id);
248
+ if (!existing) continue;
249
+ if (existing.spaceId !== targetSpaceId) {
250
+ throw new Error(
251
+ `Document "${id}" belongs to another Content space and cannot be imported here`,
252
+ );
253
+ }
254
+ await assertAccess("document", id, "editor");
255
+ }
256
+ };
257
+
258
+ await validateDocumentIds(initialSnapshot);
259
+
260
+ const metadata = parseJson(target.source.metadataJson);
261
+ const policy = truthPolicy(metadata.truthPolicy);
262
+ const now = new Date().toISOString();
263
+ const created: Array<{ id: string; path: string; title: string }> = [];
264
+ const updated: Array<{ id: string; path: string; title: string }> = [];
265
+ const unchanged: Array<{ id: string; path: string; title: string }> = [];
266
+ const skipped = [...parseErrors];
267
+ const conflicts: Array<{ id: string; path: string; title: string }> = [];
268
+ const outbound: Array<{ id: string; path: string; title: string }> = [];
269
+
270
+ const buildPlans = (snapshot: Awaited<ReturnType<typeof loadSnapshot>>) =>
271
+ valid.map((file, index) => {
272
+ const pathRow = snapshot.rowByPath.get(file.path);
273
+ const id =
274
+ file.id ??
275
+ pathRow?.documentId ??
276
+ opaqueId("content_local_file", `${sourceId}:${file.path}`);
277
+ const existing = snapshot.documentById.get(id);
278
+ const previousRow = snapshot.rowByDocumentId.get(id) ?? pathRow;
279
+ const previousValues = parseJson(previousRow?.sourceValuesJson);
280
+ const previousHash =
281
+ typeof previousValues.contentHash === "string"
282
+ ? previousValues.contentHash
283
+ : null;
284
+ const previousMetadataHash =
285
+ typeof previousValues.metadataHash === "string"
286
+ ? previousValues.metadataHash
287
+ : null;
288
+ const incomingHash = contentHash(file.content);
289
+ const incomingMetadataHash = metadataHash(file);
290
+ const localHash = existing ? contentHash(existing.content) : null;
291
+ const localMetadataHash = existing ? metadataHash(existing) : null;
292
+ const incomingChanged =
293
+ previousHash !== incomingHash ||
294
+ previousMetadataHash !== incomingMetadataHash;
295
+ const localChanged =
296
+ !!existing &&
297
+ ((!!previousHash && localHash !== previousHash) ||
298
+ (!!previousMetadataHash &&
299
+ localMetadataHash !== previousMetadataHash));
300
+ const conflict =
301
+ !!existing &&
302
+ !!previousHash &&
303
+ incomingChanged &&
304
+ (localChanged || policy === "database_primary") &&
305
+ (localHash !== incomingHash ||
306
+ localMetadataHash !== incomingMetadataHash);
307
+ const keepContent =
308
+ !!existing &&
309
+ !!previousHash &&
310
+ localChanged &&
311
+ !incomingChanged &&
312
+ policy !== "source_primary";
313
+ const applyIncoming =
314
+ incomingChanged || (localChanged && policy === "source_primary");
315
+ return {
316
+ file,
317
+ index,
318
+ id,
319
+ existing,
320
+ previousRow,
321
+ previousHash,
322
+ incomingHash,
323
+ incomingMetadataHash,
324
+ localHash,
325
+ localMetadataHash,
326
+ incomingChanged,
327
+ localChanged,
328
+ conflict,
329
+ keepContent,
330
+ applyIncoming,
331
+ };
332
+ });
333
+
334
+ let plans = buildPlans(initialSnapshot);
335
+ const initialDocumentIds = new Set(plans.map((plan) => plan.id));
336
+ let missingRows = initialSnapshot.storedRows.filter(
337
+ (row) => !initialDocumentIds.has(row.documentId),
338
+ );
339
+ const classifyPlans = (
340
+ snapshot: Awaited<ReturnType<typeof loadSnapshot>>,
341
+ ) => {
342
+ created.length = 0;
343
+ updated.length = 0;
344
+ unchanged.length = 0;
345
+ conflicts.length = 0;
346
+ outbound.length = 0;
347
+ for (const row of missingRows) {
348
+ const values = parseJson(row.sourceValuesJson);
349
+ const path = String(values.relativePath ?? row.sourceDisplayKey ?? "");
350
+ const document = snapshot.documentById.get(row.documentId);
351
+ conflicts.push({
352
+ id: row.documentId,
353
+ path,
354
+ title: document?.title ?? path,
355
+ });
356
+ }
357
+
358
+ for (const plan of plans) {
359
+ if (plan.conflict) {
360
+ conflicts.push({
361
+ id: plan.id,
362
+ path: plan.file.path,
363
+ title: plan.file.title,
364
+ });
365
+ } else if (!plan.existing) {
366
+ created.push({
367
+ id: plan.id,
368
+ path: plan.file.path,
369
+ title: plan.file.title,
370
+ });
371
+ } else if (
372
+ plan.applyIncoming ||
373
+ plan.existing.title !== plan.file.title ||
374
+ plan.existing.sourcePath !== plan.file.path
375
+ ) {
376
+ updated.push({
377
+ id: plan.id,
378
+ path: plan.file.path,
379
+ title: plan.file.title,
380
+ });
381
+ } else {
382
+ unchanged.push({
383
+ id: plan.id,
384
+ path: plan.file.path,
385
+ title: plan.file.title,
386
+ });
387
+ }
388
+ if (plan.keepContent) {
389
+ outbound.push({
390
+ id: plan.id,
391
+ path: plan.file.path,
392
+ title: plan.file.title,
393
+ });
394
+ }
395
+ }
396
+ };
397
+ classifyPlans(initialSnapshot);
398
+
399
+ if (!dryRun) {
400
+ await db.transaction(async (tx: any) => {
401
+ const claimedSources = await tx
402
+ .update(schema.contentDatabaseSources)
403
+ .set({ updatedAt: now })
404
+ .where(
405
+ and(
406
+ eq(schema.contentDatabaseSources.id, sourceId),
407
+ eq(schema.contentDatabaseSources.databaseId, target.database.id),
408
+ eq(
409
+ schema.contentDatabaseSources.sourceType,
410
+ LOCAL_FOLDER_SOURCE_TYPE,
411
+ ),
412
+ ),
413
+ )
414
+ .returning({ id: schema.contentDatabaseSources.id });
415
+ if (claimedSources.length !== 1) {
416
+ throw new Error(
417
+ `Local folder source "${sourceId}" was disconnected before sync`,
418
+ );
419
+ }
420
+ const transactionSnapshot = await loadSnapshot(tx);
421
+ await validateDocumentIds(transactionSnapshot);
422
+ plans = buildPlans(transactionSnapshot);
423
+ const currentDocumentIds = new Set(plans.map((plan) => plan.id));
424
+ missingRows = transactionSnapshot.storedRows.filter(
425
+ (row) => !currentDocumentIds.has(row.documentId),
426
+ );
427
+ classifyPlans(transactionSnapshot);
428
+ for (const plan of plans) {
429
+ if (plan.conflict) {
430
+ const changeSetId = opaqueId(
431
+ "content_source_change",
432
+ `${sourceId}:${plan.id}:${plan.incomingHash}:${plan.incomingMetadataHash}:${plan.localHash}:${plan.localMetadataHash}`,
433
+ );
434
+ await tx
435
+ .insert(schema.contentDatabaseSourceChangeSets)
436
+ .values({
437
+ id: changeSetId,
438
+ ownerEmail: target.source.ownerEmail,
439
+ sourceId,
440
+ databaseItemId: plan.previousRow?.databaseItemId ?? null,
441
+ documentId: plan.id,
442
+ kind:
443
+ plan.localHash === plan.incomingHash
444
+ ? "metadata_update"
445
+ : "body_update",
446
+ direction: "incoming",
447
+ state: "proposed",
448
+ pushMode: "none",
449
+ localOnly: 0,
450
+ summary: `Review concurrent local-folder changes for "${plan.file.title}".`,
451
+ fieldChangesJson: JSON.stringify(
452
+ metadataChanges(plan.existing!, plan.file),
453
+ ),
454
+ bodyChangeJson: bodyChangeJson({
455
+ currentHash: plan.localHash!,
456
+ incomingHash: plan.incomingHash,
457
+ currentMetadataHash: plan.localMetadataHash!,
458
+ incomingMetadataHash: plan.incomingMetadataHash,
459
+ }),
460
+ createdAt: now,
461
+ updatedAt: now,
462
+ })
463
+ .onConflictDoNothing();
464
+ continue;
465
+ }
466
+
467
+ if (plan.keepContent) {
468
+ const changeSetId = opaqueId(
469
+ "content_source_change",
470
+ `${sourceId}:${plan.id}:outbound:${plan.localHash}`,
471
+ );
472
+ await tx
473
+ .insert(schema.contentDatabaseSourceChangeSets)
474
+ .values({
475
+ id: changeSetId,
476
+ ownerEmail: target.source.ownerEmail,
477
+ sourceId,
478
+ databaseItemId: plan.previousRow?.databaseItemId ?? null,
479
+ documentId: plan.id,
480
+ kind: "body_update",
481
+ direction: "outbound",
482
+ state: "proposed",
483
+ pushMode: "none",
484
+ localOnly: 1,
485
+ summary: `Content has a newer revision of "${plan.file.title}" ready to export.`,
486
+ fieldChangesJson: "[]",
487
+ bodyChangeJson: bodyChangeJson({
488
+ currentHash: plan.incomingHash,
489
+ incomingHash: plan.localHash!,
490
+ currentMetadataHash: plan.incomingMetadataHash,
491
+ incomingMetadataHash: plan.localMetadataHash!,
492
+ }),
493
+ createdAt: now,
494
+ updatedAt: now,
495
+ })
496
+ .onConflictDoNothing();
497
+ continue;
498
+ }
499
+
500
+ if (!plan.existing) {
501
+ await tx.insert(schema.documents).values({
502
+ id: plan.id,
503
+ spaceId: target.database.spaceId,
504
+ ownerEmail: userEmail,
505
+ orgId: target.database.orgId,
506
+ parentId: null,
507
+ title: plan.file.title,
508
+ content: plan.file.content,
509
+ description: plan.file.description ?? "",
510
+ icon: plan.file.icon ?? null,
511
+ position: plan.file.position ?? plan.index,
512
+ isFavorite: plan.file.isFavorite ? 1 : 0,
513
+ hideFromSearch: plan.file.hideFromSearch ? 1 : 0,
514
+ sourceMode: "local-files",
515
+ sourceKind: "file",
516
+ sourcePath: plan.file.path,
517
+ sourceRootPath: target.source.sourceName,
518
+ sourceUpdatedAt: now,
519
+ visibility: target.database.orgId ? "org" : "private",
520
+ createdAt: now,
521
+ updatedAt: now,
522
+ });
523
+ } else if (
524
+ plan.applyIncoming ||
525
+ plan.existing.sourcePath !== plan.file.path
526
+ ) {
527
+ await tx
528
+ .insert(schema.documentVersions)
529
+ .values({
530
+ id: opaqueId(
531
+ "content_document_version",
532
+ `${plan.id}:${plan.existing.updatedAt}:${plan.incomingHash}`,
533
+ ),
534
+ ownerEmail: plan.existing.ownerEmail,
535
+ documentId: plan.id,
536
+ title: plan.existing.title,
537
+ content: plan.existing.content,
538
+ createdAt: now,
539
+ })
540
+ .onConflictDoNothing();
541
+ const reboundDocuments = await tx
542
+ .update(schema.documents)
543
+ .set({
544
+ ...(plan.applyIncoming
545
+ ? {
546
+ title: plan.file.title,
547
+ content: plan.file.content,
548
+ description:
549
+ plan.file.description ?? plan.existing.description,
550
+ icon: plan.file.icon ?? plan.existing.icon,
551
+ }
552
+ : {}),
553
+ sourceMode: "local-files",
554
+ sourceKind: "file",
555
+ sourcePath: plan.file.path,
556
+ sourceRootPath: target.source.sourceName,
557
+ sourceUpdatedAt: now,
558
+ updatedAt: now,
559
+ })
560
+ .where(
561
+ and(
562
+ eq(schema.documents.id, plan.id),
563
+ eq(schema.documents.spaceId, targetSpaceId),
564
+ ),
565
+ )
566
+ .returning({ id: schema.documents.id });
567
+ if (reboundDocuments.length !== 1) {
568
+ throw new Error(
569
+ `Document "${plan.id}" left this Content space during local-folder sync`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+
575
+ const materializedIds = plans
576
+ .filter((plan) => !plan.conflict)
577
+ .map((plan) => plan.id);
578
+ await ensureDocumentsFilesMembership(tx, materializedIds, now);
579
+ const filesItems = materializedIds.length
580
+ ? await tx
581
+ .select()
582
+ .from(schema.contentDatabaseItems)
583
+ .where(
584
+ and(
585
+ eq(
586
+ schema.contentDatabaseItems.databaseId,
587
+ target.database.id,
588
+ ),
589
+ inArray(
590
+ schema.contentDatabaseItems.documentId,
591
+ materializedIds,
592
+ ),
593
+ ),
594
+ )
595
+ : [];
596
+ const itemByDocumentId = new Map<
597
+ string,
598
+ typeof schema.contentDatabaseItems.$inferSelect
599
+ >(
600
+ filesItems.map(
601
+ (item: typeof schema.contentDatabaseItems.$inferSelect) =>
602
+ [item.documentId, item] as const,
603
+ ),
604
+ );
605
+ for (const plan of plans.filter((candidate) => !candidate.conflict)) {
606
+ if (plan.keepContent) continue;
607
+ const item = itemByDocumentId.get(plan.id);
608
+ if (!item) {
609
+ throw new Error(
610
+ `Files membership was not created for "${plan.id}"`,
611
+ );
612
+ }
613
+ const rowId = opaqueId(
614
+ "content_source_row",
615
+ `${sourceId}:${plan.id}`,
616
+ );
617
+ const rowValues = {
618
+ sourceId,
619
+ databaseItemId: item.id,
620
+ documentId: plan.id,
621
+ sourceRowId: plan.file.path,
622
+ sourceQualifiedId: `local-folder://${target.source.sourceTable}/${encodeURIComponent(plan.file.path)}`,
623
+ sourceDisplayKey: plan.file.path,
624
+ sourceValuesJson: sourceValues({
625
+ path: plan.file.path,
626
+ title: plan.file.title,
627
+ hash: plan.incomingHash,
628
+ metadataHash: plan.incomingMetadataHash,
629
+ }),
630
+ provenance: "trusted local-folder bridge",
631
+ syncState: "linked",
632
+ freshness: "fresh",
633
+ lastSyncedAt: now,
634
+ lastSourceUpdatedAt: now,
635
+ updatedAt: now,
636
+ };
637
+ await tx
638
+ .insert(schema.contentDatabaseSourceRows)
639
+ .values({
640
+ id: rowId,
641
+ ownerEmail: target.source.ownerEmail,
642
+ createdAt: now,
643
+ ...rowValues,
644
+ })
645
+ .onConflictDoNothing();
646
+ await tx
647
+ .update(schema.contentDatabaseSourceRows)
648
+ .set(rowValues)
649
+ .where(eq(schema.contentDatabaseSourceRows.id, rowId));
650
+ }
651
+ for (const row of missingRows) {
652
+ const values = parseJson(row.sourceValuesJson);
653
+ const path = String(
654
+ values.relativePath ?? row.sourceDisplayKey ?? row.sourceRowId,
655
+ );
656
+ const changeSetId = opaqueId(
657
+ "content_source_change",
658
+ `${sourceId}:${row.documentId}:source-delete:${path}`,
659
+ );
660
+ await tx
661
+ .insert(schema.contentDatabaseSourceChangeSets)
662
+ .values({
663
+ id: changeSetId,
664
+ ownerEmail: target.source.ownerEmail,
665
+ sourceId,
666
+ databaseItemId: row.databaseItemId,
667
+ documentId: row.documentId,
668
+ kind: "metadata_update",
669
+ direction: "incoming",
670
+ state: "proposed",
671
+ pushMode: "none",
672
+ localOnly: 0,
673
+ summary: `The local source no longer contains "${path}". Review before unlinking it.`,
674
+ fieldChangesJson: "[]",
675
+ bodyChangeJson: deletionChangeJson({
676
+ path,
677
+ previousHash:
678
+ typeof values.contentHash === "string"
679
+ ? values.contentHash
680
+ : null,
681
+ }),
682
+ createdAt: now,
683
+ updatedAt: now,
684
+ })
685
+ .onConflictDoNothing();
686
+ }
687
+ const refreshedSources = await tx
688
+ .update(schema.contentDatabaseSources)
689
+ .set({
690
+ syncState: conflicts.length ? "error" : "linked",
691
+ freshness: conflicts.length || outbound.length ? "stale" : "fresh",
692
+ lastRefreshedAt: now,
693
+ lastSourceUpdatedAt: now,
694
+ lastError: conflicts.length
695
+ ? `${conflicts.length} file conflict${conflicts.length === 1 ? "" : "s"} require review.`
696
+ : outbound.length
697
+ ? `${outbound.length} Content change${outbound.length === 1 ? " is" : "s are"} ready to export.`
698
+ : null,
699
+ updatedAt: now,
700
+ })
701
+ .where(eq(schema.contentDatabaseSources.id, sourceId))
702
+ .returning({ id: schema.contentDatabaseSources.id });
703
+ if (refreshedSources.length !== 1) {
704
+ throw new Error(
705
+ `Local folder source "${sourceId}" was disconnected during sync`,
706
+ );
707
+ }
708
+ });
709
+ await writeAppState("refresh-signal", { ts: Date.now() });
710
+ }
711
+
712
+ return {
713
+ sourceId,
714
+ spaceId: target.database.spaceId,
715
+ filesDatabaseId: target.database.id,
716
+ truthPolicy: policy,
717
+ dryRun,
718
+ filesSeen: entries.length,
719
+ created,
720
+ updated,
721
+ unchanged,
722
+ conflicts,
723
+ outbound,
724
+ skipped,
725
+ errors: parseErrors,
726
+ idByPath: Object.fromEntries(
727
+ plans.map((plan) => [plan.file.path, plan.id]),
728
+ ),
729
+ };
730
+ },
731
+ });