@agent-native/core 0.164.12 → 0.164.15

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 (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/_database-source-utils.ts +25 -2
  3. package/corpus/templates/content/actions/_database-utils.ts +2 -0
  4. package/corpus/templates/content/actions/_local-folder-source.ts +186 -2
  5. package/corpus/templates/content/actions/connect-local-folder-source.ts +23 -8
  6. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +3 -3
  7. package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +2 -2
  8. package/corpus/templates/content/actions/sync-local-folder-source.ts +130 -12
  9. package/corpus/templates/content/app/components/LocalFolderLiveSync.tsx +77 -0
  10. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +354 -8
  11. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +23 -1
  12. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +78 -1
  13. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +36 -0
  14. package/corpus/templates/content/app/components/editor/local-source-write-state.ts +52 -0
  15. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +244 -6
  16. package/corpus/templates/content/app/components/sidebar/local-source-hierarchy.ts +189 -0
  17. package/corpus/templates/content/app/hooks/use-create-page.ts +26 -4
  18. package/corpus/templates/content/app/hooks/use-documents.ts +83 -0
  19. package/corpus/templates/content/app/i18n-data.ts +79 -0
  20. package/corpus/templates/content/app/lib/desktop-content-files.ts +57 -1
  21. package/corpus/templates/content/app/lib/local-content-source-files.ts +210 -34
  22. package/corpus/templates/content/app/lib/local-folder-live-sync.ts +191 -0
  23. package/corpus/templates/content/app/root.tsx +2 -1
  24. package/corpus/templates/content/app/routes/_app.local-files.tsx +103 -38
  25. package/corpus/templates/content/docs/product/capabilities/content.source.file-folder.md +2 -2
  26. package/corpus/templates/content/docs/product/features/content.feature.bring-your-local-work.md +3 -3
  27. package/corpus/templates/content/docs/product/roadmap.md +2 -2
  28. package/corpus/templates/content/docs/solutions/2026-08-18-content-navigation-coherence-shape.md +482 -0
  29. package/corpus/templates/content/shared/api.ts +25 -0
  30. package/dist/app-config/auth.d.ts +4 -0
  31. package/dist/app-config/auth.js +7 -0
  32. package/dist/app-config/schema.d.ts +3 -0
  33. package/dist/app-config/schema.js +2 -0
  34. package/dist/cli/code-agent-executor.js +6 -1
  35. package/dist/cli/code-agent-mcp-config.d.ts +2 -2
  36. package/dist/cli/code-agent-mcp-config.js +34 -9
  37. package/dist/client/org/OrgSwitcher.js +8 -4
  38. package/dist/collab/routes.d.ts +1 -1
  39. package/dist/mcp-client/config.d.ts +11 -0
  40. package/dist/mcp-client/config.js +33 -26
  41. package/dist/resources/handlers.d.ts +2 -2
  42. package/dist/server/auth.js +15 -1
  43. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8473
34
+ - template files: 8478
@@ -60,6 +60,10 @@ import {
60
60
  chunks,
61
61
  processWithConcurrency,
62
62
  } from "./_batch-utils.js";
63
+ import {
64
+ LOCAL_FOLDER_SOURCE_TYPE,
65
+ localFolderSourceIdentityFromMetadata,
66
+ } from "./_local-folder-source.js";
63
67
  export { bulkChunkSizeForColumnCount } from "./_batch-utils.js";
64
68
  import {
65
69
  readBuilderCmsContentEntry,
@@ -176,6 +180,9 @@ type SourceMetadataRecord = {
176
180
  connectionId?: string | null;
177
181
  connectionLabel?: string | null;
178
182
  truthPolicy?: ContentDatabaseSource["metadata"]["truthPolicy"];
183
+ syncPolicy?: "manual" | "keep_in_sync";
184
+ liveBridgeEnabled?: boolean;
185
+ localIdentity?: unknown;
179
186
  liveReadConfigured?: boolean;
180
187
  lastReadEntryCount?: number;
181
188
  lastReadMatchedRowCount?: number;
@@ -4572,14 +4579,19 @@ async function loadSourceSnapshot(
4572
4579
  ? metadata.writeMode
4573
4580
  : undefined;
4574
4581
  const capabilities = normalizeCapabilities(source.capabilitiesJson);
4575
- if (normalizedWriteMode) {
4582
+ const sourceType = normalizeSourceType(source.sourceType);
4583
+ if (sourceType === LOCAL_FOLDER_SOURCE_TYPE) {
4584
+ capabilities.liveWritesEnabled =
4585
+ metadata.liveBridgeEnabled === true &&
4586
+ metadata.syncPolicy === "keep_in_sync";
4587
+ } else if (normalizedWriteMode) {
4576
4588
  capabilities.liveWritesEnabled = normalizedWriteMode !== "read_only";
4577
4589
  }
4578
4590
 
4579
4591
  // A local-table source shows the target database's *live* title, so renaming
4580
4592
  // the underlying table is reflected here instead of the name frozen at attach.
4581
4593
  let displaySourceName = source.sourceName;
4582
- if (normalizeSourceType(source.sourceType) === "local-table") {
4594
+ if (sourceType === "local-table") {
4583
4595
  const [target] = await db
4584
4596
  .select({ title: schema.contentDatabases.title })
4585
4597
  .from(schema.contentDatabases)
@@ -4626,6 +4638,17 @@ async function loadSourceSnapshot(
4626
4638
  metadata.truthPolicy === "reviewed_bidirectional"
4627
4639
  ? metadata.truthPolicy
4628
4640
  : undefined,
4641
+ syncPolicy:
4642
+ metadata.syncPolicy === "manual" ||
4643
+ metadata.syncPolicy === "keep_in_sync"
4644
+ ? metadata.syncPolicy
4645
+ : undefined,
4646
+ liveBridgeEnabled:
4647
+ metadata.liveBridgeEnabled === true &&
4648
+ metadata.syncPolicy === "keep_in_sync",
4649
+ localIdentity: localFolderSourceIdentityFromMetadata(
4650
+ metadata.localIdentity,
4651
+ ),
4629
4652
  liveReadConfigured: metadata.liveReadConfigured === true,
4630
4653
  lastReadEntryCount:
4631
4654
  typeof metadata.lastReadEntryCount === "number"
@@ -50,6 +50,7 @@ import {
50
50
  getDatabaseMutationContract,
51
51
  } from "./_database-row-mutation.js";
52
52
  import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js";
53
+ import { serializeDocumentSource } from "./_document-source.js";
53
54
  import {
54
55
  applyFederatedOverlayValues,
55
56
  federateSources,
@@ -443,6 +444,7 @@ function serializeDocument(
443
444
  databaseMembership: membership
444
445
  ? serializeDatabaseMembership(membership)
445
446
  : undefined,
447
+ source: serializeDocumentSource(doc),
446
448
  createdAt: doc.createdAt,
447
449
  updatedAt: doc.updatedAt,
448
450
  };
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ import { z } from "zod";
4
+
3
5
  import type {
4
6
  ContentDatabaseSourceCapabilities,
5
7
  ContentDatabaseSourceTruthPolicy,
@@ -7,6 +9,171 @@ import type {
7
9
 
8
10
  export const LOCAL_FOLDER_SOURCE_TYPE = "local-folder" as const;
9
11
 
12
+ export const localFolderOpaqueIdentitySchema = z
13
+ .string()
14
+ .trim()
15
+ .min(1)
16
+ .max(300)
17
+ .refine(
18
+ (value) =>
19
+ !value.includes("/") &&
20
+ !value.includes("\\") &&
21
+ !value.includes("..") &&
22
+ !value.includes("\0"),
23
+ "Local source identities must be opaque IDs, not paths.",
24
+ );
25
+
26
+ const labelSchema = z.string().trim().min(1).max(200);
27
+
28
+ export const localFolderSourceConnectionMetadataSchema = z
29
+ .object({
30
+ repository: z
31
+ .object({
32
+ localId: localFolderOpaqueIdentitySchema,
33
+ providerBinding: z
34
+ .object({
35
+ provider: z.literal("github"),
36
+ repositoryId: localFolderOpaqueIdentitySchema,
37
+ })
38
+ .strict()
39
+ .optional(),
40
+ })
41
+ .strict()
42
+ .optional(),
43
+ workingCopy: z
44
+ .object({
45
+ id: localFolderOpaqueIdentitySchema,
46
+ repositoryId: localFolderOpaqueIdentitySchema.optional(),
47
+ kind: z.enum(["persistent", "temporary"]),
48
+ name: labelSchema,
49
+ branch: z.string().trim().min(1).max(300).optional(),
50
+ commit: z.string().trim().min(1).max(300).optional(),
51
+ deviceId: localFolderOpaqueIdentitySchema,
52
+ })
53
+ .strict()
54
+ .optional(),
55
+ liveBridgeEnabled: z.boolean().optional().default(false),
56
+ })
57
+ .strict();
58
+
59
+ export type LocalFolderSourceConnectionMetadata = z.infer<
60
+ typeof localFolderSourceConnectionMetadataSchema
61
+ >;
62
+
63
+ export type LocalFolderSourceIdentity = {
64
+ repository?: {
65
+ localId: string;
66
+ providerBinding?: { provider: "github"; repositoryId: string };
67
+ };
68
+ workingCopy: {
69
+ id: string;
70
+ repositoryId?: string;
71
+ kind: "persistent" | "temporary";
72
+ name: string;
73
+ branch?: string;
74
+ commit?: string;
75
+ deviceId: string;
76
+ localOnly: boolean;
77
+ shareable: boolean;
78
+ };
79
+ };
80
+
81
+ export function localFolderSourceIdentityFromMetadata(
82
+ value: unknown,
83
+ ): LocalFolderSourceIdentity | undefined {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
85
+ return undefined;
86
+ }
87
+ const stored = value as {
88
+ repository?: unknown;
89
+ workingCopy?: Record<string, unknown>;
90
+ };
91
+ const connectionMetadata =
92
+ localFolderSourceConnectionMetadataSchema.safeParse({
93
+ repository: stored.repository,
94
+ workingCopy: stored.workingCopy
95
+ ? Object.fromEntries(
96
+ Object.entries(stored.workingCopy).filter(
97
+ ([key]) => key !== "localOnly" && key !== "shareable",
98
+ ),
99
+ )
100
+ : undefined,
101
+ });
102
+ if (!connectionMetadata.success || !connectionMetadata.data.workingCopy) {
103
+ return undefined;
104
+ }
105
+ try {
106
+ return normalizeLocalFolderSourceIdentity({
107
+ connectionId: "stored-local-source",
108
+ label: connectionMetadata.data.workingCopy.name,
109
+ connectionMetadata: connectionMetadata.data,
110
+ });
111
+ } catch {
112
+ // coercion-ok: invalid legacy metadata is represented as absent optional identity
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ export function normalizeLocalFolderSourceIdentity(input: {
118
+ connectionId: string;
119
+ label: string;
120
+ connectionMetadata?: LocalFolderSourceConnectionMetadata;
121
+ }): LocalFolderSourceIdentity {
122
+ const metadata = localFolderSourceConnectionMetadataSchema.parse(
123
+ input.connectionMetadata ?? {},
124
+ );
125
+ const workingCopy = metadata.workingCopy ?? {
126
+ id: input.connectionId,
127
+ kind: "persistent" as const,
128
+ name: input.label,
129
+ deviceId: "unknown-device",
130
+ };
131
+ if (
132
+ workingCopy.repositoryId &&
133
+ (!metadata.repository ||
134
+ workingCopy.repositoryId !== metadata.repository.localId)
135
+ ) {
136
+ throw new Error(
137
+ "Working-copy repositoryId must match the supplied repository identity.",
138
+ );
139
+ }
140
+ return {
141
+ ...(metadata.repository ? { repository: metadata.repository } : {}),
142
+ workingCopy: {
143
+ ...workingCopy,
144
+ localOnly: workingCopy.kind === "temporary",
145
+ shareable: workingCopy.kind !== "temporary",
146
+ },
147
+ };
148
+ }
149
+
150
+ export function localFolderObservedRevision(input: {
151
+ contentHash: string;
152
+ metadataHash: string;
153
+ }) {
154
+ return `sha256:${createHash("sha256")
155
+ .update(`${input.contentHash}:${input.metadataHash}`)
156
+ .digest("hex")}`;
157
+ }
158
+
159
+ export function localFolderSourceFileIdentity(input: {
160
+ workingCopyId: string;
161
+ relativePath: string;
162
+ observedRevision: string;
163
+ }) {
164
+ const relativePath = input.relativePath.replace(/\\/g, "/");
165
+ if (
166
+ !relativePath ||
167
+ relativePath.startsWith("/") ||
168
+ relativePath.split("/").some((part) => part === "." || part === "..")
169
+ ) {
170
+ throw new Error(
171
+ "Local source file identity requires a safe relative path.",
172
+ );
173
+ }
174
+ return { ...input, relativePath };
175
+ }
176
+
10
177
  export function localFolderSourceId(databaseId: string, connectionId: string) {
11
178
  return `content_database_source_local_folder_${createHash("sha256")
12
179
  .update(`${databaseId}:${connectionId}`)
@@ -14,7 +181,9 @@ export function localFolderSourceId(databaseId: string, connectionId: string) {
14
181
  .slice(0, 32)}`;
15
182
  }
16
183
 
17
- export function localFolderSourceCapabilities(): ContentDatabaseSourceCapabilities {
184
+ export function localFolderSourceCapabilities(input?: {
185
+ liveBridgeEnabled?: boolean;
186
+ }): ContentDatabaseSourceCapabilities {
18
187
  return {
19
188
  canRefresh: true,
20
189
  canCreateChangeSets: true,
@@ -25,7 +194,7 @@ export function localFolderSourceCapabilities(): ContentDatabaseSourceCapabiliti
25
194
  canPublish: false,
26
195
  canDelete: true,
27
196
  canStageLocalRevision: true,
28
- liveWritesEnabled: false,
197
+ liveWritesEnabled: input?.liveBridgeEnabled === true,
29
198
  readOnlyRefresh: false,
30
199
  canRename: true,
31
200
  canReveal: true,
@@ -37,7 +206,16 @@ export function localFolderSourceMetadata(input: {
37
206
  connectionId: string;
38
207
  label: string;
39
208
  truthPolicy: ContentDatabaseSourceTruthPolicy;
209
+ connectionMetadata?: LocalFolderSourceConnectionMetadata;
40
210
  }) {
211
+ const connectionMetadata = localFolderSourceConnectionMetadataSchema.parse(
212
+ input.connectionMetadata ?? {},
213
+ );
214
+ const identity = normalizeLocalFolderSourceIdentity({
215
+ connectionId: input.connectionId,
216
+ label: input.label,
217
+ connectionMetadata,
218
+ });
41
219
  return {
42
220
  primaryKey: "relative_path",
43
221
  naturalKeyField: "relative_path",
@@ -47,6 +225,12 @@ export function localFolderSourceMetadata(input: {
47
225
  connectionLabel: input.label,
48
226
  truthPolicy: input.truthPolicy,
49
227
  writeMode: "stage_only" as const,
228
+ syncPolicy:
229
+ connectionMetadata.liveBridgeEnabled === true
230
+ ? ("keep_in_sync" as const)
231
+ : ("manual" as const),
232
+ liveBridgeEnabled: connectionMetadata.liveBridgeEnabled === true,
233
+ localIdentity: identity,
50
234
  notes:
51
235
  "The trusted local bridge owns the folder handle or path; SQL stores only this opaque connection identity.",
52
236
  };
@@ -17,6 +17,8 @@ import {
17
17
  } from "./_content-spaces.js";
18
18
  import {
19
19
  LOCAL_FOLDER_SOURCE_TYPE,
20
+ localFolderSourceConnectionMetadataSchema,
21
+ localFolderOpaqueIdentitySchema,
20
22
  localFolderSourceCapabilities,
21
23
  localFolderSourceId,
22
24
  localFolderSourceMetadata,
@@ -30,12 +32,15 @@ export default defineAction({
30
32
  description:
31
33
  "Connect an opaque trusted local-folder handle to a canonical Files database. The browser or Desktop bridge keeps the real handle/path; Content stores only safe connection metadata.",
32
34
  schema: z.object({
33
- connectionId: z
34
- .string()
35
- .min(1)
36
- .max(300)
37
- .describe("Stable opaque ID from the trusted browser/Desktop registry"),
35
+ connectionId: localFolderOpaqueIdentitySchema.describe(
36
+ "Stable opaque ID from the trusted browser/Desktop registry",
37
+ ),
38
38
  label: z.string().min(1).max(200).describe("User-visible folder label"),
39
+ connectionMetadata: localFolderSourceConnectionMetadataSchema
40
+ .optional()
41
+ .describe(
42
+ "Safe repository/working-copy identity from the trusted local bridge; raw paths and handles are not accepted.",
43
+ ),
39
44
  spaceId: z
40
45
  .string()
41
46
  .optional()
@@ -70,9 +75,10 @@ export default defineAction({
70
75
  }
71
76
 
72
77
  const db = getDb();
73
- const connectionId = args.connectionId.trim();
78
+ const connectionId = args.connectionId;
74
79
  const label = args.label.trim();
75
80
  const truthPolicy = args.truthPolicy as ContentDatabaseSourceTruthPolicy;
81
+ const connectionMetadata = args.connectionMetadata;
76
82
  if (args.dryRun) {
77
83
  const [existing] = await db
78
84
  .select({
@@ -185,9 +191,18 @@ export default defineAction({
185
191
  sourceTable: connectionId,
186
192
  syncState: "linked",
187
193
  freshness: "unknown",
188
- capabilitiesJson: JSON.stringify(localFolderSourceCapabilities()),
194
+ capabilitiesJson: JSON.stringify(
195
+ localFolderSourceCapabilities({
196
+ liveBridgeEnabled: connectionMetadata?.liveBridgeEnabled,
197
+ }),
198
+ ),
189
199
  metadataJson: JSON.stringify(
190
- localFolderSourceMetadata({ connectionId, label, truthPolicy }),
200
+ localFolderSourceMetadata({
201
+ connectionId,
202
+ label,
203
+ truthPolicy,
204
+ connectionMetadata,
205
+ }),
191
206
  ),
192
207
  lastError: null,
193
208
  createdAt: now,
@@ -80,7 +80,7 @@ export default defineAction({
80
80
  documentId: string;
81
81
  sourceDisplayKey: string | null;
82
82
  sourceValuesJson: string | null;
83
- sourceName: string;
83
+ sourceTable: string;
84
84
  };
85
85
  const remainingLocalRows: RemainingLocalRow[] = documentIds.length
86
86
  ? await tx
@@ -90,7 +90,7 @@ export default defineAction({
90
90
  schema.contentDatabaseSourceRows.sourceDisplayKey,
91
91
  sourceValuesJson:
92
92
  schema.contentDatabaseSourceRows.sourceValuesJson,
93
- sourceName: schema.contentDatabaseSources.sourceName,
93
+ sourceTable: schema.contentDatabaseSources.sourceTable,
94
94
  })
95
95
  .from(schema.contentDatabaseSourceRows)
96
96
  .innerJoin(
@@ -152,7 +152,7 @@ export default defineAction({
152
152
  sourceMode: "local-files",
153
153
  sourceKind: "file",
154
154
  sourcePath: relativePath,
155
- sourceRootPath: remaining.sourceName,
155
+ sourceRootPath: remaining.sourceTable,
156
156
  sourceUpdatedAt: now,
157
157
  updatedAt: now,
158
158
  })
@@ -227,7 +227,7 @@ export default defineAction({
227
227
  .select({
228
228
  sourceDisplayKey: schema.contentDatabaseSourceRows.sourceDisplayKey,
229
229
  sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
230
- sourceName: schema.contentDatabaseSources.sourceName,
230
+ sourceTable: schema.contentDatabaseSources.sourceTable,
231
231
  })
232
232
  .from(schema.contentDatabaseSourceRows)
233
233
  .innerJoin(
@@ -267,7 +267,7 @@ export default defineAction({
267
267
  sourceMode: "local-files",
268
268
  sourceKind: "file",
269
269
  sourcePath: remainingPath ?? null,
270
- sourceRootPath: remainingLocalRow.sourceName,
270
+ sourceRootPath: remainingLocalRow.sourceTable,
271
271
  sourceUpdatedAt: now,
272
272
  updatedAt: now,
273
273
  }
@@ -19,7 +19,13 @@ import { ensureDocumentsFilesMembership } from "./_content-files.js";
19
19
  import { resolveContentSpaceAccess } from "./_content-space-access.js";
20
20
  import { provisionContentSpaces } from "./_content-spaces.js";
21
21
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
22
- import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js";
22
+ import {
23
+ LOCAL_FOLDER_SOURCE_TYPE,
24
+ localFolderObservedRevision,
25
+ localFolderSourceFileIdentity,
26
+ localFolderSourceIdentityFromMetadata,
27
+ normalizeLocalFolderSourceIdentity,
28
+ } from "./_local-folder-source.js";
23
29
 
24
30
  const MAX_SOURCE_FILES = 500;
25
31
  const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
@@ -75,13 +81,28 @@ function sourceValues(args: {
75
81
  title: string;
76
82
  hash: string;
77
83
  metadataHash: string;
84
+ workingCopyId: string;
85
+ bridgeFileIdentity?: string;
78
86
  }) {
87
+ const observedRevision = localFolderObservedRevision({
88
+ contentHash: args.hash,
89
+ metadataHash: args.metadataHash,
90
+ });
79
91
  return JSON.stringify({
80
92
  relativePath: args.path,
81
93
  extension: args.path.toLowerCase().endsWith(".mdx") ? ".mdx" : ".md",
82
94
  title: args.title,
83
95
  contentHash: args.hash,
84
96
  metadataHash: args.metadataHash,
97
+ observedRevision,
98
+ sourceFileIdentity: localFolderSourceFileIdentity({
99
+ workingCopyId: args.workingCopyId,
100
+ relativePath: args.path,
101
+ observedRevision,
102
+ }),
103
+ ...(args.bridgeFileIdentity
104
+ ? { bridgeFileIdentity: args.bridgeFileIdentity }
105
+ : {}),
85
106
  });
86
107
  }
87
108
 
@@ -142,9 +163,19 @@ export default defineAction({
142
163
  .refine((value) => Object.keys(value).length <= MAX_SOURCE_FILES, {
143
164
  message: `Sync is limited to ${MAX_SOURCE_FILES} files.`,
144
165
  }),
166
+ fileIdentities: z.record(z.string(), z.string().min(1).max(256)).optional(),
167
+ observedRevisions: z
168
+ .record(z.string(), z.string().regex(/^[a-f0-9]{64}$/i))
169
+ .optional(),
145
170
  dryRun: z.boolean().optional().default(false),
146
171
  }),
147
- run: async ({ sourceId, files, dryRun }) => {
172
+ run: async ({
173
+ sourceId,
174
+ files,
175
+ fileIdentities = {},
176
+ observedRevisions,
177
+ dryRun,
178
+ }) => {
148
179
  const userEmail = getRequestUserEmail();
149
180
  if (!userEmail) throw new Error("no authenticated user");
150
181
  const builderPaths = Object.keys(files).filter(isBuilderMdxSourcePath);
@@ -154,6 +185,15 @@ export default defineAction({
154
185
  );
155
186
  }
156
187
  const entries = normalizedEntries(files);
188
+ if (observedRevisions) {
189
+ for (const [filePath, content] of entries) {
190
+ if (observedRevisions[filePath] !== contentHash(content)) {
191
+ throw new Error(
192
+ `Local file revision changed while reading "${filePath}"`,
193
+ );
194
+ }
195
+ }
196
+ }
157
197
  const db = getDb();
158
198
  const [target] = await db
159
199
  .select({
@@ -263,6 +303,12 @@ export default defineAction({
263
303
 
264
304
  const metadata = parseJson(target.source.metadataJson);
265
305
  const policy = truthPolicy(metadata.truthPolicy);
306
+ const localIdentity =
307
+ localFolderSourceIdentityFromMetadata(metadata.localIdentity) ??
308
+ normalizeLocalFolderSourceIdentity({
309
+ connectionId: target.source.sourceTable,
310
+ label: target.source.sourceName,
311
+ });
266
312
  const now = new Date().toISOString();
267
313
  const created: Array<{ id: string; path: string; title: string }> = [];
268
314
  const updated: Array<{ id: string; path: string; title: string }> = [];
@@ -271,16 +317,42 @@ export default defineAction({
271
317
  const conflicts: Array<{ id: string; path: string; title: string }> = [];
272
318
  const outbound: Array<{ id: string; path: string; title: string }> = [];
273
319
 
274
- const buildPlans = (snapshot: Awaited<ReturnType<typeof loadSnapshot>>) =>
275
- valid.map((file, index) => {
276
- const pathRow = snapshot.rowByPath.get(file.path);
320
+ const buildPlans = (snapshot: Awaited<ReturnType<typeof loadSnapshot>>) => {
321
+ const rowByBridgeIdentity = new Map<
322
+ string,
323
+ (typeof snapshot.storedRows)[number]
324
+ >();
325
+ for (const row of snapshot.storedRows) {
326
+ const values = parseJson(row.sourceValuesJson);
327
+ if (typeof values.bridgeFileIdentity === "string") {
328
+ rowByBridgeIdentity.set(values.bridgeFileIdentity, row);
329
+ }
330
+ }
331
+
332
+ return valid.map((file, index) => {
333
+ const directPathRow = snapshot.rowByPath.get(file.path);
334
+ const incomingBridgeFileIdentity = fileIdentities[file.path];
335
+ const identityRow = incomingBridgeFileIdentity
336
+ ? rowByBridgeIdentity.get(incomingBridgeFileIdentity)
337
+ : undefined;
338
+ const incomingHash = contentHash(file.content);
339
+ const pathRow = directPathRow ?? identityRow;
340
+ const explicitId =
341
+ file.id && localIdentity.workingCopy.kind === "temporary"
342
+ ? opaqueId("content_local_file", `${sourceId}:${file.id}`)
343
+ : file.id;
277
344
  const id =
278
- file.id ??
345
+ explicitId ??
279
346
  pathRow?.documentId ??
280
347
  opaqueId("content_local_file", `${sourceId}:${file.path}`);
281
348
  const existing = snapshot.documentById.get(id);
282
349
  const previousRow = snapshot.rowByDocumentId.get(id) ?? pathRow;
283
350
  const previousValues = parseJson(previousRow?.sourceValuesJson);
351
+ const bridgeFileIdentity =
352
+ incomingBridgeFileIdentity ??
353
+ (typeof previousValues.bridgeFileIdentity === "string"
354
+ ? previousValues.bridgeFileIdentity
355
+ : undefined);
284
356
  const previousHash =
285
357
  typeof previousValues.contentHash === "string"
286
358
  ? previousValues.contentHash
@@ -289,7 +361,6 @@ export default defineAction({
289
361
  typeof previousValues.metadataHash === "string"
290
362
  ? previousValues.metadataHash
291
363
  : null;
292
- const incomingHash = contentHash(file.content);
293
364
  const incomingMetadataHash = metadataHash(file);
294
365
  const localHash = existing ? contentHash(existing.content) : null;
295
366
  const localMetadataHash = existing ? metadataHash(existing) : null;
@@ -332,8 +403,10 @@ export default defineAction({
332
403
  conflict,
333
404
  keepContent,
334
405
  applyIncoming,
406
+ bridgeFileIdentity,
335
407
  };
336
408
  });
409
+ };
337
410
 
338
411
  let plans = buildPlans(initialSnapshot);
339
412
  const initialDocumentIds = new Set(plans.map((plan) => plan.id));
@@ -348,7 +421,7 @@ export default defineAction({
348
421
  unchanged.length = 0;
349
422
  conflicts.length = 0;
350
423
  outbound.length = 0;
351
- for (const row of missingRows) {
424
+ for (const row of policy === "source_primary" ? [] : missingRows) {
352
425
  const values = parseJson(row.sourceValuesJson);
353
426
  const path = String(values.relativePath ?? row.sourceDisplayKey ?? "");
354
427
  const document = snapshot.documentById.get(row.documentId);
@@ -375,7 +448,8 @@ export default defineAction({
375
448
  } else if (
376
449
  plan.applyIncoming ||
377
450
  plan.existing.title !== plan.file.title ||
378
- plan.existing.sourcePath !== plan.file.path
451
+ plan.existing.sourcePath !== plan.file.path ||
452
+ plan.existing.sourceRootPath !== target.source.sourceTable
379
453
  ) {
380
454
  updated.push({
381
455
  id: plan.id,
@@ -518,7 +592,7 @@ export default defineAction({
518
592
  sourceMode: "local-files",
519
593
  sourceKind: "file",
520
594
  sourcePath: plan.file.path,
521
- sourceRootPath: target.source.sourceName,
595
+ sourceRootPath: target.source.sourceTable,
522
596
  sourceUpdatedAt: now,
523
597
  visibility: target.database.orgId ? "org" : "private",
524
598
  createdAt: now,
@@ -526,7 +600,8 @@ export default defineAction({
526
600
  });
527
601
  } else if (
528
602
  plan.applyIncoming ||
529
- plan.existing.sourcePath !== plan.file.path
603
+ plan.existing.sourcePath !== plan.file.path ||
604
+ plan.existing.sourceRootPath !== target.source.sourceTable
530
605
  ) {
531
606
  await tx
532
607
  .insert(schema.documentVersions)
@@ -557,7 +632,7 @@ export default defineAction({
557
632
  sourceMode: "local-files",
558
633
  sourceKind: "file",
559
634
  sourcePath: plan.file.path,
560
- sourceRootPath: target.source.sourceName,
635
+ sourceRootPath: target.source.sourceTable,
561
636
  sourceUpdatedAt: now,
562
637
  updatedAt: now,
563
638
  })
@@ -647,6 +722,8 @@ export default defineAction({
647
722
  title: plan.file.title,
648
723
  hash: plan.incomingHash,
649
724
  metadataHash: plan.incomingMetadataHash,
725
+ workingCopyId: localIdentity.workingCopy.id,
726
+ bridgeFileIdentity: plan.bridgeFileIdentity,
650
727
  }),
651
728
  provenance: "trusted local-folder bridge",
652
729
  syncState: "linked",
@@ -670,6 +747,47 @@ export default defineAction({
670
747
  .where(eq(schema.contentDatabaseSourceRows.id, rowId));
671
748
  }
672
749
  for (const row of missingRows) {
750
+ if (policy === "source_primary") {
751
+ await tx
752
+ .delete(schema.contentDatabaseSourceRows)
753
+ .where(eq(schema.contentDatabaseSourceRows.id, row.id));
754
+ const [remainingSourceRow] = await tx
755
+ .select({ id: schema.contentDatabaseSourceRows.id })
756
+ .from(schema.contentDatabaseSourceRows)
757
+ .where(
758
+ eq(
759
+ schema.contentDatabaseSourceRows.databaseItemId,
760
+ row.databaseItemId,
761
+ ),
762
+ )
763
+ .limit(1);
764
+ if (!remainingSourceRow) {
765
+ await tx
766
+ .delete(schema.contentDatabaseItems)
767
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
768
+ await tx
769
+ .update(schema.documents)
770
+ .set({
771
+ sourceMode: null,
772
+ sourceKind: null,
773
+ sourcePath: null,
774
+ sourceRootPath: null,
775
+ sourceUpdatedAt: now,
776
+ updatedAt: now,
777
+ })
778
+ .where(
779
+ and(
780
+ eq(schema.documents.id, row.documentId),
781
+ eq(schema.documents.spaceId, targetSpaceId),
782
+ eq(
783
+ schema.documents.sourceRootPath,
784
+ target.source.sourceTable,
785
+ ),
786
+ ),
787
+ );
788
+ }
789
+ continue;
790
+ }
673
791
  const values = parseJson(row.sourceValuesJson);
674
792
  const path = String(
675
793
  values.relativePath ?? row.sourceDisplayKey ?? row.sourceRowId,