@agent-native/core 0.114.0 → 0.114.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 (162) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +9 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/build-compatibility.ts +77 -0
  5. package/corpus/core/src/client/route-chunk-recovery/index.ts +1 -0
  6. package/corpus/core/src/client/use-action.ts +32 -1
  7. package/corpus/core/src/deploy/build.ts +4 -1
  8. package/corpus/core/src/org/context.ts +56 -30
  9. package/corpus/core/src/server/action-routes.ts +52 -2
  10. package/corpus/core/src/server/auth-plugin.ts +9 -4
  11. package/corpus/core/src/server/better-auth-instance.ts +5 -1
  12. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  13. package/corpus/core/src/vite/client.ts +17 -0
  14. package/corpus/templates/content/AGENTS.md +2 -0
  15. package/corpus/templates/content/actions/_content-database-personal-view.ts +49 -5
  16. package/corpus/templates/content/actions/_content-document-access.ts +21 -0
  17. package/corpus/templates/content/actions/_content-favorites.ts +91 -0
  18. package/corpus/templates/content/actions/_content-files.ts +79 -23
  19. package/corpus/templates/content/actions/_content-sidebar-state.ts +24 -0
  20. package/corpus/templates/content/actions/_content-space-access.ts +15 -17
  21. package/corpus/templates/content/actions/_content-space-catalog-guards.ts +20 -0
  22. package/corpus/templates/content/actions/_content-spaces.ts +364 -167
  23. package/corpus/templates/content/actions/_database-source-utils.ts +8 -0
  24. package/corpus/templates/content/actions/_database-utils.ts +249 -15
  25. package/corpus/templates/content/actions/_delete-content-space.ts +84 -0
  26. package/corpus/templates/content/actions/_files-system-properties.ts +499 -0
  27. package/corpus/templates/content/actions/_local-file-documents.ts +0 -1
  28. package/corpus/templates/content/actions/_property-utils.ts +18 -9
  29. package/corpus/templates/content/actions/bind-content-database-source-field.ts +3 -0
  30. package/corpus/templates/content/actions/configure-document-property.ts +3 -0
  31. package/corpus/templates/content/actions/create-content-database.ts +2 -2
  32. package/corpus/templates/content/actions/create-content-space.ts +34 -0
  33. package/corpus/templates/content/actions/create-document.ts +4 -1
  34. package/corpus/templates/content/actions/delete-content-space.ts +19 -0
  35. package/corpus/templates/content/actions/delete-database-items.ts +28 -1
  36. package/corpus/templates/content/actions/delete-document-property.ts +3 -0
  37. package/corpus/templates/content/actions/delete-document.ts +39 -2
  38. package/corpus/templates/content/actions/duplicate-database-item.ts +6 -0
  39. package/corpus/templates/content/actions/duplicate-database-items.ts +6 -0
  40. package/corpus/templates/content/actions/duplicate-document-property.ts +3 -0
  41. package/corpus/templates/content/actions/get-content-database.ts +11 -2
  42. package/corpus/templates/content/actions/get-content-sidebar-state.ts +23 -0
  43. package/corpus/templates/content/actions/get-document.ts +32 -7
  44. package/corpus/templates/content/actions/import-content-source.ts +33 -1
  45. package/corpus/templates/content/actions/list-content-spaces.ts +34 -2
  46. package/corpus/templates/content/actions/list-documents.ts +39 -7
  47. package/corpus/templates/content/actions/reorder-document-property.ts +9 -0
  48. package/corpus/templates/content/actions/set-document-property.ts +41 -23
  49. package/corpus/templates/content/actions/share-local-file-document.ts +15 -0
  50. package/corpus/templates/content/actions/submit-content-database-form.ts +7 -0
  51. package/corpus/templates/content/actions/sync-local-folder-source.ts +14 -0
  52. package/corpus/templates/content/actions/update-content-database-view.ts +1 -0
  53. package/corpus/templates/content/actions/update-content-sidebar-state.ts +27 -0
  54. package/corpus/templates/content/actions/update-document.ts +158 -66
  55. package/corpus/templates/content/app/components/EmptyState.tsx +4 -57
  56. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +8 -1
  57. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +519 -178
  58. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +39 -0
  59. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  60. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +266 -7
  61. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +430 -152
  62. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +467 -66
  63. package/corpus/templates/content/app/components/editor/database/view-config.ts +9 -3
  64. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +720 -196
  65. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +173 -1
  66. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +1 -1
  67. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +91 -16
  68. package/corpus/templates/content/app/entry.client.tsx +5 -1
  69. package/corpus/templates/content/app/hooks/use-content-database.ts +84 -3
  70. package/corpus/templates/content/app/hooks/use-content-spaces.ts +60 -2
  71. package/corpus/templates/content/app/hooks/use-create-page.ts +30 -0
  72. package/corpus/templates/content/app/hooks/use-documents.ts +270 -19
  73. package/corpus/templates/content/app/i18n-data.ts +243 -20
  74. package/corpus/templates/content/app/lib/local-folder-picker-safety.ts +54 -0
  75. package/corpus/templates/content/app/lib/local-folder-picker-support.ts +18 -0
  76. package/corpus/templates/content/app/routes/_app.favorites.tsx +30 -0
  77. package/corpus/templates/content/app/routes/_app.local-files.tsx +18 -6
  78. package/corpus/templates/content/changelog/2026-07-17-workspace-files-now-stay-in-a-loading-state-until-automatic-.md +6 -0
  79. package/corpus/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md +6 -0
  80. package/corpus/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md +6 -0
  81. package/corpus/templates/content/changelog/2026-07-18-create-named-workspaces-each-with-its-own-private-files-data.md +6 -0
  82. package/corpus/templates/content/changelog/2026-07-18-favorites-align-with-workspace-and-file-rows-in-the-sidebar.md +6 -0
  83. package/corpus/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md +6 -0
  84. package/corpus/templates/content/changelog/2026-07-18-made-content-workspaces-independently-expandable-restored-or.md +6 -0
  85. package/corpus/templates/content/changelog/2026-07-18-page-details-now-live-in-a-shared-info-and-comments-rail-dat.md +6 -0
  86. package/corpus/templates/content/changelog/2026-07-18-workspace-database-views-now-control-sidebar-workspace-navig.md +6 -0
  87. package/corpus/templates/content/changelog/2026-07-18-workspace-navigation-now-aligns-favorites-with-workspace-pag.md +6 -0
  88. package/corpus/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md +6 -0
  89. package/corpus/templates/content/changelog/2026-07-19-files-databases-now-start-unfiltered-sidebar-rows-exactly-fo.md +6 -0
  90. package/corpus/templates/content/changelog/2026-07-19-sidebar-page-trees-now-open-only-when-expanded-remember-thei.md +6 -0
  91. package/corpus/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md +6 -0
  92. package/corpus/templates/content/changelog/2026-07-19-workspace-navigation-now-stays-stable-on-hover-uses-folder-i.md +6 -0
  93. package/corpus/templates/content/changelog/2026-07-19-workspace-rows-now-use-folder-icons-and-deleting-a-user-crea.md +6 -0
  94. package/corpus/templates/content/changelog/2026-07-19-workspaces-can-be-created-without-leaving-the-workspaces-dat.md +6 -0
  95. package/corpus/templates/content/e2e/global-setup.ts +3 -2
  96. package/corpus/templates/content/parity/matrix.md +28 -28
  97. package/corpus/templates/content/parity/matrix.ts +8 -2
  98. package/corpus/templates/content/server/db/index.ts +1 -0
  99. package/corpus/templates/content/server/db/schema.ts +10 -0
  100. package/corpus/templates/content/server/lib/documents.ts +12 -7
  101. package/corpus/templates/content/server/plugins/db.ts +17 -0
  102. package/corpus/templates/content/shared/api.ts +9 -0
  103. package/corpus/templates/content/shared/database-form.ts +11 -2
  104. package/corpus/templates/content/vite.config.ts +1 -0
  105. package/dist/client/build-compatibility.d.ts +11 -0
  106. package/dist/client/build-compatibility.d.ts.map +1 -0
  107. package/dist/client/build-compatibility.js +56 -0
  108. package/dist/client/build-compatibility.js.map +1 -0
  109. package/dist/client/route-chunk-recovery/index.d.ts +1 -0
  110. package/dist/client/route-chunk-recovery/index.d.ts.map +1 -1
  111. package/dist/client/route-chunk-recovery/index.js +1 -0
  112. package/dist/client/route-chunk-recovery/index.js.map +1 -1
  113. package/dist/client/use-action.d.ts.map +1 -1
  114. package/dist/client/use-action.js +19 -1
  115. package/dist/client/use-action.js.map +1 -1
  116. package/dist/collab/routes.d.ts +1 -1
  117. package/dist/collab/struct-routes.d.ts +1 -1
  118. package/dist/deploy/build.js +2 -1
  119. package/dist/deploy/build.js.map +1 -1
  120. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  121. package/dist/notifications/routes.d.ts +1 -1
  122. package/dist/observability/routes.d.ts +5 -5
  123. package/dist/org/context.d.ts +4 -2
  124. package/dist/org/context.d.ts.map +1 -1
  125. package/dist/org/context.js +44 -22
  126. package/dist/org/context.js.map +1 -1
  127. package/dist/progress/routes.d.ts +1 -1
  128. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  129. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  130. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  131. package/dist/resources/handlers.d.ts +1 -1
  132. package/dist/secrets/routes.d.ts +9 -9
  133. package/dist/server/action-routes.d.ts.map +1 -1
  134. package/dist/server/action-routes.js +32 -2
  135. package/dist/server/action-routes.js.map +1 -1
  136. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  137. package/dist/server/auth-plugin.d.ts.map +1 -1
  138. package/dist/server/auth-plugin.js +9 -5
  139. package/dist/server/auth-plugin.js.map +1 -1
  140. package/dist/server/better-auth-instance.d.ts.map +1 -1
  141. package/dist/server/better-auth-instance.js +4 -1
  142. package/dist/server/better-auth-instance.js.map +1 -1
  143. package/dist/server/transcribe-voice.d.ts +1 -1
  144. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  145. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  146. package/dist/shared/mcp-embed-headers.js +1 -1
  147. package/dist/shared/mcp-embed-headers.js.map +1 -1
  148. package/dist/vite/client.d.ts +6 -0
  149. package/dist/vite/client.d.ts.map +1 -1
  150. package/dist/vite/client.js +8 -0
  151. package/dist/vite/client.js.map +1 -1
  152. package/package.json +1 -1
  153. package/src/client/build-compatibility.ts +77 -0
  154. package/src/client/route-chunk-recovery/index.ts +1 -0
  155. package/src/client/use-action.ts +32 -1
  156. package/src/deploy/build.ts +4 -1
  157. package/src/org/context.ts +56 -30
  158. package/src/server/action-routes.ts +52 -2
  159. package/src/server/auth-plugin.ts +9 -4
  160. package/src/server/better-auth-instance.ts +5 -1
  161. package/src/shared/mcp-embed-headers.ts +1 -1
  162. package/src/vite/client.ts +17 -0
@@ -1,14 +1,24 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
- import { and, eq, sql } from "drizzle-orm";
3
+ import { and, eq, inArray, sql } from "drizzle-orm";
4
4
 
5
5
  import { schema } from "../server/db/index.js";
6
+ import {
7
+ isComputedPropertyType,
8
+ type DocumentPropertyType,
9
+ } from "../shared/properties.js";
6
10
  import {
7
11
  listContentOrganizationMemberships,
8
12
  normalizeContentSpaceEmail,
9
13
  } from "./_content-space-access.js";
14
+ import {
15
+ defaultFilesDatabaseViewConfig,
16
+ ensureFilesSystemPropertyDefinitions,
17
+ } from "./_files-system-properties.js";
18
+ import { withPositionLock } from "./_position-utils.js";
10
19
  import {
11
20
  defaultDatabaseViewConfig,
21
+ normalizedValueJson,
12
22
  seedDefaultBlocksField,
13
23
  serializeDatabaseViewConfig,
14
24
  } from "./_property-utils.js";
@@ -19,6 +29,8 @@ export type ProvisionedContentSpaces = {
19
29
  personalSpaceId: string;
20
30
  personalFilesDatabaseId: string;
21
31
  catalogDatabaseId: string;
32
+ favoritesDatabaseId: string;
33
+ favoritesDocumentId: string;
22
34
  spaceIds: string[];
23
35
  created: {
24
36
  spaces: number;
@@ -50,9 +62,16 @@ export function sourceBackedContentSpaceId(
50
62
  );
51
63
  }
52
64
 
65
+ export function userContentSpaceId(email: string, workspaceId: string) {
66
+ return opaqueId(
67
+ "content_space_user",
68
+ `${normalizeContentSpaceEmail(email)}:${workspaceId.trim()}`,
69
+ );
70
+ }
71
+
53
72
  export function systemIdsForContentSpace(
54
73
  scope: string,
55
- role: "files" | "workspaces",
74
+ role: "files" | "workspaces" | "favorites",
56
75
  ) {
57
76
  return {
58
77
  databaseId: opaqueId(`content_database_${role}`, scope),
@@ -90,7 +109,7 @@ async function ensureSystemDatabase(args: {
90
109
  ownerEmail: string;
91
110
  orgId: string | null;
92
111
  title: string;
93
- role: "files" | "workspaces";
112
+ role: "files" | "workspaces" | "favorites";
94
113
  visibility: "private" | "org";
95
114
  now: string;
96
115
  created: ProvisionedContentSpaces["created"];
@@ -137,7 +156,9 @@ async function ensureSystemDatabase(args: {
137
156
  title: args.title,
138
157
  systemRole: args.role,
139
158
  viewConfigJson: serializeDatabaseViewConfig(
140
- defaultDatabaseViewConfig("sidebar"),
159
+ args.role === "files"
160
+ ? defaultFilesDatabaseViewConfig(ids.databaseId)
161
+ : defaultDatabaseViewConfig("table"),
141
162
  ),
142
163
  createdAt: args.now,
143
164
  updatedAt: args.now,
@@ -158,6 +179,22 @@ async function ensureSystemDatabase(args: {
158
179
  throw new Error(
159
180
  `Unable to provision ${args.role} database for Content space`,
160
181
  );
182
+ if (args.role === "files" && database.title === "Files") {
183
+ await args.db
184
+ .update(schema.contentDatabases)
185
+ .set({ title: args.title, updatedAt: args.now })
186
+ .where(eq(schema.contentDatabases.id, database.id));
187
+ await args.db
188
+ .update(schema.documents)
189
+ .set({ title: args.title, updatedAt: args.now })
190
+ .where(eq(schema.documents.id, database.documentId));
191
+ database.title = args.title;
192
+ }
193
+ await ensureFilesSystemPropertyDefinitions({
194
+ database,
195
+ db: args.db,
196
+ now: args.now,
197
+ });
161
198
  return database;
162
199
  }
163
200
 
@@ -214,26 +251,30 @@ export async function provisionContentSpaces(
214
251
  .databaseId,
215
252
  catalogDatabaseId: systemIdsForContentSpace(personalSpaceId, "workspaces")
216
253
  .databaseId,
254
+ favoritesDatabaseId: systemIdsForContentSpace(personalSpaceId, "favorites")
255
+ .databaseId,
256
+ favoritesDocumentId: systemIdsForContentSpace(personalSpaceId, "favorites")
257
+ .documentId,
217
258
  spaceIds: [],
218
259
  created: { spaces: 0, databases: 0, documents: 0, catalogItems: 0 },
219
260
  };
220
261
 
221
262
  await db.transaction(async (tx: Db) => {
263
+ const [personalSpace] = await tx
264
+ .select()
265
+ .from(schema.contentSpaces)
266
+ .where(eq(schema.contentSpaces.id, personalSpaceId));
222
267
  const personalFiles = await ensureSystemDatabase({
223
268
  db: tx,
224
269
  spaceId: personalSpaceId,
225
270
  ownerEmail: email,
226
271
  orgId: null,
227
- title: "Files",
272
+ title: personalSpace?.name ?? "Personal",
228
273
  role: "files",
229
274
  visibility: "private",
230
275
  now,
231
276
  created: result.created,
232
277
  });
233
- const [personalSpace] = await tx
234
- .select()
235
- .from(schema.contentSpaces)
236
- .where(eq(schema.contentSpaces.id, personalSpaceId));
237
278
  if (!personalSpace) {
238
279
  await tx
239
280
  .insert(schema.contentSpaces)
@@ -267,20 +308,31 @@ export async function provisionContentSpaces(
267
308
  now,
268
309
  created: result.created,
269
310
  });
270
- await ensureDatabaseItem({
311
+ const [existingFavorites] = await tx
312
+ .select({ id: schema.contentDatabases.id })
313
+ .from(schema.contentDatabases)
314
+ .where(
315
+ and(
316
+ eq(schema.contentDatabases.spaceId, personalSpaceId),
317
+ eq(schema.contentDatabases.systemRole, "favorites"),
318
+ ),
319
+ );
320
+ const favorites = await ensureSystemDatabase({
271
321
  db: tx,
272
- databaseId: personalFiles.id,
273
- documentId: catalog.documentId,
322
+ spaceId: personalSpaceId,
274
323
  ownerEmail: email,
275
324
  orgId: null,
276
- position: 0,
325
+ title: "Favorites",
326
+ role: "favorites",
327
+ visibility: "private",
277
328
  now,
329
+ created: result.created,
278
330
  });
279
331
 
280
332
  const spaces = [
281
333
  {
282
334
  id: personalSpaceId,
283
- name: "Personal",
335
+ name: personalSpace?.name ?? "Personal",
284
336
  ownerEmail: email,
285
337
  orgId: null as string | null,
286
338
  createdBy: email,
@@ -298,31 +350,23 @@ export async function provisionContentSpaces(
298
350
  ).databaseId,
299
351
  })),
300
352
  ];
301
- const provisionableOrganizationSpaceIds = new Set(
302
- memberships
303
- .filter(
304
- (membership) =>
305
- membership.role === "owner" || membership.role === "admin",
306
- )
307
- .map((membership) => organizationContentSpaceId(membership.orgId)),
308
- );
309
353
  for (const space of spaces.slice(1)) {
310
- if (!provisionableOrganizationSpaceIds.has(space.id)) continue;
354
+ const [existingSpace] = await tx
355
+ .select()
356
+ .from(schema.contentSpaces)
357
+ .where(eq(schema.contentSpaces.id, space.id));
358
+ if (existingSpace) space.name = existingSpace.name;
311
359
  const files = await ensureSystemDatabase({
312
360
  db: tx,
313
361
  spaceId: space.id,
314
362
  ownerEmail: space.ownerEmail,
315
363
  orgId: space.orgId,
316
- title: "Files",
364
+ title: space.name,
317
365
  role: "files",
318
366
  visibility: "org",
319
367
  now,
320
368
  created: result.created,
321
369
  });
322
- const [existingSpace] = await tx
323
- .select()
324
- .from(schema.contentSpaces)
325
- .where(eq(schema.contentSpaces.id, space.id));
326
370
  if (!existingSpace) {
327
371
  await tx
328
372
  .insert(schema.contentSpaces)
@@ -335,20 +379,65 @@ export async function provisionContentSpaces(
335
379
  })
336
380
  .onConflictDoNothing();
337
381
  result.created.spaces += 1;
338
- } else if (
339
- existingSpace.name !== space.name ||
340
- existingSpace.filesDatabaseId !== files.id
341
- ) {
382
+ }
383
+ if (existingSpace && existingSpace.filesDatabaseId !== files.id) {
342
384
  await tx
343
385
  .update(schema.contentSpaces)
344
386
  .set({
345
- name: space.name,
346
387
  filesDatabaseId: files.id,
347
388
  updatedAt: now,
348
389
  })
349
390
  .where(eq(schema.contentSpaces.id, space.id));
350
391
  }
351
392
  }
393
+ if (!existingFavorites) {
394
+ const accessibleSpaceIds = spaces.map((space) => space.id);
395
+ const legacyFavorites: Array<typeof schema.documents.$inferSelect> =
396
+ await tx
397
+ .select()
398
+ .from(schema.documents)
399
+ .where(
400
+ and(
401
+ eq(schema.documents.isFavorite, 1),
402
+ inArray(schema.documents.spaceId, accessibleSpaceIds),
403
+ ),
404
+ );
405
+ const roleByOrgId = new Map(
406
+ memberships.map((membership) => [membership.orgId, membership.role]),
407
+ );
408
+ const visibleLegacyFavorites = legacyFavorites.filter((document) => {
409
+ if (!document.orgId) {
410
+ return normalizeContentSpaceEmail(document.ownerEmail) === email;
411
+ }
412
+ return (
413
+ normalizeContentSpaceEmail(document.ownerEmail) === email ||
414
+ document.visibility === "org" ||
415
+ document.visibility === "public" ||
416
+ roleByOrgId.get(document.orgId) === "owner" ||
417
+ roleByOrgId.get(document.orgId) === "admin"
418
+ );
419
+ });
420
+ if (visibleLegacyFavorites.length > 0) {
421
+ await tx
422
+ .insert(schema.contentDatabaseItems)
423
+ .values(
424
+ visibleLegacyFavorites.map((document, position) => ({
425
+ id: opaqueId(
426
+ "content_database_item",
427
+ `${favorites.id}:${document.id}`,
428
+ ),
429
+ ownerEmail: email,
430
+ orgId: null,
431
+ databaseId: favorites.id,
432
+ documentId: document.id,
433
+ position,
434
+ createdAt: now,
435
+ updatedAt: now,
436
+ })),
437
+ )
438
+ .onConflictDoNothing();
439
+ }
440
+ }
352
441
  const accessibleIds = new Set(spaces.map((space) => space.id));
353
442
  for (const [index, space] of spaces.entries()) {
354
443
  if (!accessibleIds.has(space.id)) continue;
@@ -395,15 +484,6 @@ export async function provisionContentSpaces(
395
484
  position: index,
396
485
  now,
397
486
  });
398
- await ensureDatabaseItem({
399
- db: tx,
400
- databaseId: personalFiles.id,
401
- documentId: referenceDocumentId,
402
- ownerEmail: email,
403
- orgId: null,
404
- position: index + 1,
405
- now,
406
- });
407
487
  const [existingCatalogItem] = await tx
408
488
  .select({ id: schema.contentSpaceCatalogItems.id })
409
489
  .from(schema.contentSpaceCatalogItems)
@@ -451,7 +531,6 @@ export async function provisionContentSpaces(
451
531
  db,
452
532
  });
453
533
  for (const membership of memberships) {
454
- if (membership.role !== "owner" && membership.role !== "admin") continue;
455
534
  const spaceId = organizationContentSpaceId(membership.orgId);
456
535
  const [database] = await db
457
536
  .select({
@@ -484,21 +563,24 @@ export async function provisionContentSpaces(
484
563
  return result;
485
564
  }
486
565
 
487
- export async function provisionSourceBackedContentSpace(
566
+ async function provisionOwnedContentSpace(
488
567
  db: Db,
489
568
  userEmail: string,
490
- input: { connectionId: string; name: string },
569
+ input: {
570
+ spaceId: string;
571
+ name: string;
572
+ kind: "user" | "source_backed";
573
+ propertyValues?: Record<string, unknown>;
574
+ },
491
575
  ) {
492
576
  const email = normalizeContentSpaceEmail(userEmail);
493
- const connectionId = input.connectionId.trim();
494
- const name = input.name.trim() || "Local folder";
495
- if (!connectionId) throw new Error("Local folder connection ID is required");
577
+ const name = input.name.trim();
578
+ if (!name) throw new Error("Workspace name is required");
496
579
  await provisionContentSpaces(db, email);
497
580
 
498
581
  const now = new Date().toISOString();
499
- const spaceId = sourceBackedContentSpaceId(email, connectionId);
582
+ const spaceId = input.spaceId;
500
583
  const personalSpaceId = personalContentSpaceId(email);
501
- const personalFilesIds = systemIdsForContentSpace(personalSpaceId, "files");
502
584
  const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces");
503
585
  const created: ProvisionedContentSpaces["created"] = {
504
586
  spaces: 0,
@@ -507,134 +589,249 @@ export async function provisionSourceBackedContentSpace(
507
589
  catalogItems: 0,
508
590
  };
509
591
 
510
- const files = await db.transaction(async (tx: Db) => {
511
- const sourceFiles = await ensureSystemDatabase({
512
- db: tx,
513
- spaceId,
514
- ownerEmail: email,
515
- orgId: null,
516
- title: "Files",
517
- role: "files",
518
- visibility: "private",
519
- now,
520
- created,
521
- });
522
- const [existingSpace] = await tx
523
- .select()
524
- .from(schema.contentSpaces)
525
- .where(eq(schema.contentSpaces.id, spaceId));
526
- if (!existingSpace) {
527
- await tx
528
- .insert(schema.contentSpaces)
529
- .values({
530
- id: spaceId,
531
- name,
532
- kind: "source_backed",
533
- ownerEmail: email,
534
- orgId: null,
535
- filesDatabaseId: sourceFiles.id,
536
- createdBy: email,
537
- createdAt: now,
538
- updatedAt: now,
539
- })
540
- .onConflictDoNothing();
541
- } else if (
542
- existingSpace.name !== name ||
543
- existingSpace.filesDatabaseId !== sourceFiles.id
544
- ) {
545
- await tx
546
- .update(schema.contentSpaces)
547
- .set({ name, filesDatabaseId: sourceFiles.id, updatedAt: now })
592
+ const provisioned = await withPositionLock<{
593
+ files: typeof schema.contentDatabases.$inferSelect;
594
+ name: string;
595
+ catalogDatabaseId: string;
596
+ catalogItemId: string;
597
+ catalogDocumentId: string;
598
+ }>(`contentSpace:${spaceId}`, () =>
599
+ db.transaction(async (tx: Db) => {
600
+ const [existingSpace] = await tx
601
+ .select()
602
+ .from(schema.contentSpaces)
548
603
  .where(eq(schema.contentSpaces.id, spaceId));
549
- }
550
-
551
- const referenceDocumentId = opaqueId(
552
- "content_workspace_reference",
553
- `${email}:${spaceId}`,
554
- );
555
- await ensureDocument(
556
- tx,
557
- {
558
- id: referenceDocumentId,
559
- spaceId: personalSpaceId,
604
+ const sourceFiles = await ensureSystemDatabase({
605
+ db: tx,
606
+ spaceId,
560
607
  ownerEmail: email,
561
608
  orgId: null,
562
- parentId: catalogIds.documentId,
563
- title: name,
564
- content: "",
565
- description: "",
566
- position: 0,
567
- isFavorite: 0,
568
- hideFromSearch: 0,
609
+ title: existingSpace?.name ?? name,
610
+ role: "files",
569
611
  visibility: "private",
570
- createdAt: now,
571
- updatedAt: now,
572
- },
573
- created,
574
- );
575
- await tx
576
- .update(schema.documents)
577
- .set({ title: name, updatedAt: now })
578
- .where(eq(schema.documents.id, referenceDocumentId));
579
-
580
- const [maxCatalogPosition] = await tx
581
- .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
582
- .from(schema.contentDatabaseItems)
583
- .where(eq(schema.contentDatabaseItems.databaseId, catalogIds.databaseId));
584
- const catalogItemId = await ensureDatabaseItem({
585
- db: tx,
586
- databaseId: catalogIds.databaseId,
587
- documentId: referenceDocumentId,
588
- ownerEmail: email,
589
- orgId: null,
590
- position: (maxCatalogPosition?.max ?? -1) + 1,
591
- now,
592
- });
593
- await ensureDatabaseItem({
594
- db: tx,
595
- databaseId: personalFilesIds.databaseId,
596
- documentId: referenceDocumentId,
597
- ownerEmail: email,
598
- orgId: null,
599
- position: (maxCatalogPosition?.max ?? -1) + 1,
600
- now,
601
- });
602
- const [mapping] = await tx
603
- .select({ id: schema.contentSpaceCatalogItems.id })
604
- .from(schema.contentSpaceCatalogItems)
605
- .where(
606
- and(
607
- eq(
608
- schema.contentSpaceCatalogItems.catalogDatabaseId,
609
- catalogIds.databaseId,
610
- ),
611
- eq(schema.contentSpaceCatalogItems.spaceId, spaceId),
612
- ),
612
+ now,
613
+ created,
614
+ });
615
+ if (
616
+ existingSpace &&
617
+ input.kind === "user" &&
618
+ existingSpace.name !== name
619
+ ) {
620
+ throw new Error(
621
+ "Workspace request ID is already bound to another name",
622
+ );
623
+ }
624
+ let createdSpace = false;
625
+ if (!existingSpace) {
626
+ const inserted = await tx
627
+ .insert(schema.contentSpaces)
628
+ .values({
629
+ id: spaceId,
630
+ name,
631
+ kind: input.kind,
632
+ ownerEmail: email,
633
+ orgId: null,
634
+ filesDatabaseId: sourceFiles.id,
635
+ createdBy: email,
636
+ createdAt: now,
637
+ updatedAt: now,
638
+ })
639
+ .onConflictDoNothing()
640
+ .returning({ id: schema.contentSpaces.id });
641
+ createdSpace = inserted.length > 0;
642
+ } else if (existingSpace.filesDatabaseId !== sourceFiles.id) {
643
+ await tx
644
+ .update(schema.contentSpaces)
645
+ .set({ filesDatabaseId: sourceFiles.id, updatedAt: now })
646
+ .where(eq(schema.contentSpaces.id, spaceId));
647
+ }
648
+ const [canonicalSpace] = await tx
649
+ .select()
650
+ .from(schema.contentSpaces)
651
+ .where(eq(schema.contentSpaces.id, spaceId));
652
+ if (!canonicalSpace)
653
+ throw new Error("Unable to create Content workspace");
654
+ if (input.kind === "user" && canonicalSpace.name !== name) {
655
+ throw new Error(
656
+ "Workspace request ID is already bound to another name",
657
+ );
658
+ }
659
+ const referenceDocumentId = opaqueId(
660
+ "content_workspace_reference",
661
+ `${email}:${spaceId}`,
613
662
  );
614
- if (!mapping) {
615
- await tx
616
- .insert(schema.contentSpaceCatalogItems)
617
- .values({
618
- id: opaqueId("content_space_catalog", `${email}:${spaceId}`),
663
+ await ensureDocument(
664
+ tx,
665
+ {
666
+ id: referenceDocumentId,
667
+ spaceId: personalSpaceId,
619
668
  ownerEmail: email,
620
- catalogDatabaseId: catalogIds.databaseId,
621
- databaseItemId: catalogItemId,
622
- documentId: referenceDocumentId,
623
- spaceId,
669
+ orgId: null,
670
+ parentId: catalogIds.documentId,
671
+ title: canonicalSpace.name,
672
+ content: "",
673
+ description: "",
674
+ position: 0,
675
+ isFavorite: 0,
676
+ hideFromSearch: 0,
677
+ visibility: "private",
624
678
  createdAt: now,
625
679
  updatedAt: now,
626
- })
627
- .onConflictDoNothing();
628
- }
629
- return sourceFiles;
630
- });
680
+ },
681
+ created,
682
+ );
683
+ if (createdSpace) {
684
+ await tx
685
+ .update(schema.documents)
686
+ .set({ title: canonicalSpace.name, updatedAt: now })
687
+ .where(eq(schema.documents.id, referenceDocumentId));
688
+ }
689
+
690
+ const [maxCatalogPosition] = await tx
691
+ .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
692
+ .from(schema.contentDatabaseItems)
693
+ .where(
694
+ eq(schema.contentDatabaseItems.databaseId, catalogIds.databaseId),
695
+ );
696
+ const catalogItemId = await ensureDatabaseItem({
697
+ db: tx,
698
+ databaseId: catalogIds.databaseId,
699
+ documentId: referenceDocumentId,
700
+ ownerEmail: email,
701
+ orgId: null,
702
+ position: (maxCatalogPosition?.max ?? -1) + 1,
703
+ now,
704
+ });
705
+ const [mapping] = await tx
706
+ .select({ id: schema.contentSpaceCatalogItems.id })
707
+ .from(schema.contentSpaceCatalogItems)
708
+ .where(
709
+ and(
710
+ eq(
711
+ schema.contentSpaceCatalogItems.catalogDatabaseId,
712
+ catalogIds.databaseId,
713
+ ),
714
+ eq(schema.contentSpaceCatalogItems.spaceId, spaceId),
715
+ ),
716
+ );
717
+ if (!mapping) {
718
+ await tx
719
+ .insert(schema.contentSpaceCatalogItems)
720
+ .values({
721
+ id: opaqueId("content_space_catalog", `${email}:${spaceId}`),
722
+ ownerEmail: email,
723
+ catalogDatabaseId: catalogIds.databaseId,
724
+ databaseItemId: catalogItemId,
725
+ documentId: referenceDocumentId,
726
+ spaceId,
727
+ createdAt: now,
728
+ updatedAt: now,
729
+ })
730
+ .onConflictDoNothing();
731
+ }
732
+ const initialPropertyValues = Object.entries(input.propertyValues ?? {});
733
+ if (createdSpace && initialPropertyValues.length > 0) {
734
+ const definitions = (await tx
735
+ .select()
736
+ .from(schema.documentPropertyDefinitions)
737
+ .where(
738
+ and(
739
+ eq(
740
+ schema.documentPropertyDefinitions.databaseId,
741
+ catalogIds.databaseId,
742
+ ),
743
+ inArray(
744
+ schema.documentPropertyDefinitions.id,
745
+ initialPropertyValues.map(([propertyId]) => propertyId),
746
+ ),
747
+ ),
748
+ )) as Array<typeof schema.documentPropertyDefinitions.$inferSelect>;
749
+ const definitionById = new Map(
750
+ definitions.map((definition) => [definition.id, definition]),
751
+ );
752
+ for (const [propertyId, value] of initialPropertyValues) {
753
+ const definition = definitionById.get(propertyId);
754
+ const type = definition?.type as DocumentPropertyType | undefined;
755
+ if (!type || isComputedPropertyType(type)) continue;
756
+ await tx
757
+ .insert(schema.documentPropertyValues)
758
+ .values({
759
+ id: opaqueId(
760
+ "content_workspace_property",
761
+ `${email}:${spaceId}:${propertyId}`,
762
+ ),
763
+ ownerEmail: email,
764
+ documentId: referenceDocumentId,
765
+ propertyId,
766
+ valueJson: normalizedValueJson(type, value),
767
+ createdAt: now,
768
+ updatedAt: now,
769
+ })
770
+ .onConflictDoUpdate({
771
+ target: schema.documentPropertyValues.id,
772
+ set: {
773
+ valueJson: normalizedValueJson(type, value),
774
+ updatedAt: now,
775
+ },
776
+ });
777
+ }
778
+ }
779
+ return {
780
+ files: sourceFiles,
781
+ name: canonicalSpace.name,
782
+ catalogDatabaseId: catalogIds.databaseId,
783
+ catalogItemId,
784
+ catalogDocumentId: referenceDocumentId,
785
+ };
786
+ }),
787
+ );
631
788
 
632
789
  await seedDefaultBlocksField({
633
- databaseId: files.id,
634
- ownerEmail: files.ownerEmail,
635
- orgId: files.orgId,
790
+ databaseId: provisioned.files.id,
791
+ ownerEmail: provisioned.files.ownerEmail,
792
+ orgId: provisioned.files.orgId,
636
793
  now,
637
794
  db,
638
795
  });
639
- return { spaceId, filesDatabaseId: files.id };
796
+ return {
797
+ spaceId,
798
+ name: provisioned.name,
799
+ filesDatabaseId: provisioned.files.id,
800
+ catalogDatabaseId: provisioned.catalogDatabaseId,
801
+ catalogItemId: provisioned.catalogItemId,
802
+ catalogDocumentId: provisioned.catalogDocumentId,
803
+ };
804
+ }
805
+
806
+ export async function provisionSourceBackedContentSpace(
807
+ db: Db,
808
+ userEmail: string,
809
+ input: { connectionId: string; name: string },
810
+ ) {
811
+ const connectionId = input.connectionId.trim();
812
+ if (!connectionId) throw new Error("Local folder connection ID is required");
813
+ return provisionOwnedContentSpace(db, userEmail, {
814
+ spaceId: sourceBackedContentSpaceId(userEmail, connectionId),
815
+ name: input.name.trim() || "Local folder",
816
+ kind: "source_backed",
817
+ });
818
+ }
819
+
820
+ export async function provisionUserContentSpace(
821
+ db: Db,
822
+ userEmail: string,
823
+ input: {
824
+ workspaceId: string;
825
+ name: string;
826
+ propertyValues?: Record<string, unknown>;
827
+ },
828
+ ) {
829
+ const workspaceId = input.workspaceId.trim();
830
+ if (!workspaceId) throw new Error("Workspace ID is required");
831
+ return provisionOwnedContentSpace(db, userEmail, {
832
+ spaceId: userContentSpaceId(userEmail, workspaceId),
833
+ name: input.name,
834
+ kind: "user",
835
+ propertyValues: input.propertyValues,
836
+ });
640
837
  }