@agent-native/core 0.197.0-nightly-20260929172956 → 0.197.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +10 -0
  3. package/corpus/templates/assets/CHANGELOG.md +8 -0
  4. package/corpus/templates/calendar/CHANGELOG.md +18 -0
  5. package/corpus/templates/clips/CHANGELOG.md +19 -0
  6. package/corpus/templates/content/CHANGELOG.md +16 -0
  7. package/corpus/templates/content/actions/_database-property-setup.ts +33 -2
  8. package/corpus/templates/content/actions/_database-setup-discovery.ts +4 -4
  9. package/corpus/templates/content/actions/_property-utils.ts +9 -1
  10. package/corpus/templates/content/actions/_relation-values.ts +291 -0
  11. package/corpus/templates/content/actions/configure-document-property.ts +53 -1
  12. package/corpus/templates/content/actions/search-content-database-rows.ts +124 -0
  13. package/corpus/templates/content/actions/set-document-property.ts +21 -1
  14. package/corpus/templates/content/agent-native.config.ts +5 -0
  15. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +892 -189
  16. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -2
  17. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +4 -1
  18. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -2
  19. package/corpus/templates/content/app/hooks/use-document-properties.ts +65 -5
  20. package/corpus/templates/content/app/i18n-data.ts +28 -0
  21. package/corpus/templates/content/changelog/2026-09-28-link-rows-across-collections-with-relation-properties.md +6 -0
  22. package/corpus/templates/content/changelog/2026-09-29-related-pages-can-be-loaded-past-the-first-25-results.md +6 -0
  23. package/corpus/templates/content/parity/matrix.md +1 -1
  24. package/corpus/templates/content/parity/matrix.ts +1 -0
  25. package/corpus/templates/content/shared/api.ts +21 -0
  26. package/corpus/templates/content/shared/properties.ts +19 -0
  27. package/corpus/templates/crm/CHANGELOG.md +8 -0
  28. package/corpus/templates/design/CHANGELOG.md +33 -0
  29. package/corpus/templates/design/actions/clone-creative-context-design-native.ts +12 -3
  30. package/corpus/templates/design/actions/generate-design.ts +2 -29
  31. package/corpus/templates/design/actions/update-design.ts +33 -8
  32. package/corpus/templates/design/app/components/design/design-canvas/LocalNetworkAccessPrompt.tsx +2 -2
  33. package/corpus/templates/design/changelog/2026-09-29-design-tweak-controls-stay-intact-when-an-edit-contains-inva.md +6 -0
  34. package/corpus/templates/design/changelog/2026-09-29-the-chrome-fallback-guide-shows-the-local-network-allow-opti.md +6 -0
  35. package/corpus/templates/design/shared/tweak-definition-schema.ts +30 -0
  36. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  37. package/corpus/templates/mail/CHANGELOG.md +34 -0
  38. package/corpus/templates/mail/app/components/email/EmailList.tsx +29 -28
  39. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +20 -8
  40. package/corpus/templates/mail/app/pages/InboxPage.tsx +34 -11
  41. package/corpus/templates/mail/changelog/2026-09-29-mail-loading-layout.md +6 -0
  42. package/corpus/templates/mail/changelog/2026-09-29-mail-shows-the-first-messages-sooner-and-keeps-them-visible-.md +6 -0
  43. package/corpus/templates/mail/server/lib/inbox-sync.ts +11 -7
  44. package/corpus/templates/slides/CHANGELOG.md +19 -0
  45. package/dist/cli/create-tui.d.ts +36 -0
  46. package/dist/cli/create-tui.js +604 -0
  47. package/dist/cli/create.d.ts +23 -1
  48. package/dist/cli/create.js +667 -136
  49. package/dist/cli/index.js +26 -5
  50. package/dist/client/AppShellSkeleton.js +97 -54
  51. package/dist/collab/awareness.d.ts +2 -2
  52. package/dist/collab/routes.d.ts +1 -1
  53. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  54. package/dist/localization/core-messages/ar-SA.js +2 -2
  55. package/dist/localization/core-messages/de-DE.js +2 -2
  56. package/dist/localization/core-messages/en-US.d.ts +2 -2
  57. package/dist/localization/core-messages/en-US.js +2 -2
  58. package/dist/localization/core-messages/es-ES.js +2 -2
  59. package/dist/localization/core-messages/fr-FR.js +2 -2
  60. package/dist/localization/core-messages/hi-IN.js +2 -2
  61. package/dist/localization/core-messages/ja-JP.js +2 -2
  62. package/dist/localization/core-messages/ko-KR.js +2 -2
  63. package/dist/localization/core-messages/pt-BR.js +2 -2
  64. package/dist/localization/core-messages/zh-CN.js +2 -2
  65. package/dist/localization/core-messages/zh-TW.js +2 -2
  66. package/dist/localization/core-messages.d.ts +2 -2
  67. package/dist/localization/default-messages.js +2 -2
  68. package/dist/org/index.d.ts +2 -2
  69. package/dist/progress/routes.d.ts +1 -1
  70. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  71. package/dist/provider-api/actions/provider-api.d.ts +9 -9
  72. package/dist/secrets/routes.d.ts +10 -10
  73. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  74. package/dist/templates/default/app/i18n/ar-SA.ts +3 -2
  75. package/dist/templates/default/app/i18n/de-DE.ts +3 -2
  76. package/dist/templates/default/app/i18n/en-US.ts +2 -2
  77. package/dist/templates/default/app/i18n/es-ES.ts +3 -2
  78. package/dist/templates/default/app/i18n/fr-FR.ts +3 -2
  79. package/dist/templates/default/app/i18n/hi-IN.ts +2 -3
  80. package/dist/templates/default/app/i18n/ja-JP.ts +2 -2
  81. package/dist/templates/default/app/i18n/ko-KR.ts +2 -2
  82. package/dist/templates/default/app/i18n/pt-BR.ts +2 -2
  83. package/dist/templates/default/app/i18n/zh-CN.ts +2 -2
  84. package/dist/templates/default/app/i18n/zh-TW.ts +2 -2
  85. package/docs/content/versioning-and-stability.mdx +3 -2
  86. package/package.json +7 -6
  87. package/src/templates/default/app/i18n/ar-SA.ts +3 -2
  88. package/src/templates/default/app/i18n/de-DE.ts +3 -2
  89. package/src/templates/default/app/i18n/en-US.ts +2 -2
  90. package/src/templates/default/app/i18n/es-ES.ts +3 -2
  91. package/src/templates/default/app/i18n/fr-FR.ts +3 -2
  92. package/src/templates/default/app/i18n/hi-IN.ts +2 -3
  93. package/src/templates/default/app/i18n/ja-JP.ts +2 -2
  94. package/src/templates/default/app/i18n/ko-KR.ts +2 -2
  95. package/src/templates/default/app/i18n/pt-BR.ts +2 -2
  96. package/src/templates/default/app/i18n/zh-CN.ts +2 -2
  97. package/src/templates/default/app/i18n/zh-TW.ts +2 -2
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: 10665
34
+ - template files: 10675
@@ -3,10 +3,20 @@
3
3
  All notable user-facing changes to Agent-Native Analytics are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-29
7
+
8
+ ### Improved
9
+
10
+ - Settings pages now share the account layout, with dated updates that load in batches.
11
+
6
12
  ## 2026-09-28
7
13
 
8
14
  ### Improved
9
15
 
16
+ - Analytics starts faster on hosted serverless deployments.
17
+ - The app canvas now meets the right chat sidebar without a dark seam or rounded corner.
18
+ - Settings now opens in the new layout, with Account, Connections, Agent, Organization, and app sections.
19
+ - The chat sidebar matches the app navigation color, with tighter composer spacing.
10
20
  - Ask Analytics now starts with a focused title.
11
21
 
12
22
  ## 2026-09-27
@@ -3,10 +3,18 @@
3
3
  All notable user-facing changes to Assets are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-29
7
+
8
+ ### Improved
9
+
10
+ - Settings pages now share the account layout, with dated updates that load in batches.
11
+
6
12
  ## 2026-09-28
7
13
 
8
14
  ### Improved
9
15
 
16
+ - The app canvas now meets the right chat sidebar without a dark seam or rounded corner.
17
+ - Settings now opens in the new layout, with Account, Connections, Agent, Organization, and app sections.
10
18
  - Create starter prompts now appear below the composer.
11
19
 
12
20
  ## 2026-09-27
@@ -3,6 +3,24 @@
3
3
  All notable user-facing changes to Agent-Native Calendar are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-29
7
+
8
+ ### Improved
9
+
10
+ - Settings pages now share the account layout, with dated updates that load in batches.
11
+
12
+ ## 2026-09-28
13
+
14
+ ### Improved
15
+
16
+ - The app canvas now meets the right chat sidebar without a dark seam or rounded corner.
17
+ - Booking link host, custom-field, visibility, and delete controls now live in a collapsible Advanced section.
18
+ - Settings now opens in the new layout, with Account, Connections, Agent, Organization, and app sections.
19
+
20
+ ### Fixed
21
+
22
+ - Public booking pages no longer get stuck loading.
23
+
6
24
  ## 2026-09-27
7
25
 
8
26
  ### Improved
@@ -3,14 +3,33 @@
3
3
  All notable user-facing changes to Clips are documented here. Open it any time
4
4
  from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-29
7
+
8
+ ### Improved
9
+
10
+ - Settings pages now share the account layout, with dated updates that load in batches.
11
+
6
12
  ## 2026-09-28
7
13
 
14
+ ### Added
15
+
16
+ - Clips can capture screenshots, annotate them, redact sensitive details, and share them.
17
+
8
18
  ### Improved
9
19
 
20
+ - The app canvas now meets the right chat sidebar without a dark seam or rounded corner.
21
+ - Settings now opens in the new layout, with Account, Connections, Agent, Organization, and app sections.
22
+ - The chat sidebar matches the app navigation color, with tighter composer spacing.
23
+ - The recording viewer keeps Agent, comments, and transcripts together, and the app sidebar now moves the full page.
10
24
  - Viewers can share recordings with agents from shared recording menus
11
25
 
12
26
  ### Fixed
13
27
 
28
+ - Clip share previews show a branded image while the recording thumbnail is unavailable.
29
+ - Meeting and feature-flag polling resumes after your session is accepted again
30
+ - Older recording links preserve the selected viewer tab when panel and Agent sidebar state are both present
31
+ - Opening Agent from a recording keeps the latest selected text attached to your prompt.
32
+ - Stop repeated Clips requests after session expiry
14
33
  - Agent links stay valid when clip details change
15
34
 
16
35
  ### Security
@@ -3,8 +3,24 @@
3
3
  All notable user-facing changes to Agent-Native Content are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new").
5
5
 
6
+ ## 2026-09-29
7
+
8
+ ### Improved
9
+
10
+ - Related pages can be loaded past the first 25 results
11
+
12
+ ## 2026-09-28
13
+
14
+ ### Added
15
+
16
+ - Link rows across collections with relation properties
17
+
6
18
  ## 2026-09-26
7
19
 
20
+ ### Improved
21
+
22
+ - Command search now shows matching titles instantly from documents already loaded in the sidebar, filling in richer results as the server responds.
23
+
8
24
  ### Fixed
9
25
 
10
26
  - An empty Recent list now loads without an error after first run.
@@ -23,6 +23,7 @@ import {
23
23
  setupGuardSchema,
24
24
  } from "./_database-setup-mutation.js";
25
25
  import { nanoid } from "./_property-utils.js";
26
+ import { assertRelationTargetDatabase } from "./_relation-values.js";
26
27
 
27
28
  export const ordinaryPropertyTypes = [
28
29
  "text",
@@ -40,6 +41,12 @@ export const ordinaryPropertyTypes = [
40
41
  "phone",
41
42
  ] as const;
42
43
 
44
+ /** Types an agent can create through property setup. */
45
+ export const setupPropertyTypes = [
46
+ ...ordinaryPropertyTypes,
47
+ "relation",
48
+ ] as const;
49
+
43
50
  const optionPropertyTypes = ["select", "multi_select", "status"] as const;
44
51
  const nonOptionPropertyTypes = [
45
52
  "number",
@@ -97,6 +104,17 @@ const createDefinitionSchema = z.discriminatedUnion("type", [
97
104
  propertyDefinitionBase
98
105
  .extend({ type: z.enum(nonOptionPropertyTypes) })
99
106
  .strict(),
107
+ propertyDefinitionBase
108
+ .extend({
109
+ type: z.literal("relation"),
110
+ relatedDatabaseId: z
111
+ .string()
112
+ .min(1)
113
+ .describe(
114
+ "Exact ID of the collection this relation links to, in the same Content space (may be this collection)",
115
+ ),
116
+ })
117
+ .strict(),
100
118
  ]);
101
119
 
102
120
  const optionEditSchema = z.discriminatedUnion("operation", [
@@ -449,8 +467,21 @@ export async function runConfigureDocumentProperty(
449
467
  if (input.operation === "create") {
450
468
  const propertyId = nanoid();
451
469
  const definition = input.definition;
452
- const options =
453
- "options" in definition ? { options: definition.options ?? [] } : {};
470
+ const options: DocumentPropertyOptions =
471
+ definition.type === "relation"
472
+ ? {
473
+ relation: {
474
+ databaseId: (
475
+ await assertRelationTargetDatabase(tx, {
476
+ sourceDatabase: context.database,
477
+ targetDatabaseId: definition.relatedDatabaseId,
478
+ })
479
+ ).id,
480
+ },
481
+ }
482
+ : "options" in definition
483
+ ? { options: definition.options ?? [] }
484
+ : {};
454
485
  if ("options" in definition) validateOptions(definition.options ?? []);
455
486
  const [maxPosition] = await tx
456
487
  .select({ max: sql<unknown>`COALESCE(MAX(position), -1)` })
@@ -5,7 +5,7 @@ import type {
5
5
  ContentDatabaseMutationContract,
6
6
  ContentDatabaseSetupContract,
7
7
  } from "../shared/api.js";
8
- import { ordinaryPropertyTypes } from "./_database-property-setup.js";
8
+ import { setupPropertyTypes } from "./_database-property-setup.js";
9
9
  import { parseDatabaseViewConfig } from "./_property-utils.js";
10
10
 
11
11
  export async function getDatabaseSetupContract(
@@ -30,14 +30,14 @@ export async function getDatabaseSetupContract(
30
30
  viewId: view.id,
31
31
  url: `${databaseUrl}?viewId=${encodeURIComponent(view.id)}`,
32
32
  })),
33
- supportedPropertyTypes: [...ordinaryPropertyTypes],
33
+ supportedPropertyTypes: [...setupPropertyTypes],
34
34
  canEditSchema: canEdit,
35
35
  canEditViews: canEdit,
36
36
  canManageLifecycle:
37
37
  (role === "admin" || role === "owner") && !database.ownerDocumentId,
38
38
  sourceComposition: "unsupported",
39
39
  properties: mutationContract.properties.map((property) => {
40
- const ordinary = ordinaryPropertyTypes.some(
40
+ const ordinary = setupPropertyTypes.some(
41
41
  (type) => type === property.type,
42
42
  );
43
43
  const reason = !canEdit
@@ -45,7 +45,7 @@ export async function getDatabaseSetupContract(
45
45
  : property.sourceManaged
46
46
  ? "Source-managed definition"
47
47
  : !ordinary
48
- ? "Blocks, computed and relationship definitions use their own actions"
48
+ ? "Blocks and computed definitions use their own actions"
49
49
  : null;
50
50
  return { propertyId: property.id, editable: reason === null, reason };
51
51
  }),
@@ -58,6 +58,7 @@ import {
58
58
  propertyDefinitionsPositionScope,
59
59
  withPositionLock,
60
60
  } from "./_position-utils.js";
61
+ import { withRelationTargets } from "./_relation-values.js";
61
62
 
62
63
  type DocumentRow = InferSelectModel<typeof schema.documents>;
63
64
  type ContentDatabaseRow = InferSelectModel<typeof schema.contentDatabases>;
@@ -810,7 +811,9 @@ export async function listPropertiesForDatabase(
810
811
  }
811
812
  }
812
813
 
813
- return nextProperties;
814
+ if (!includeContainerDerivedValues) return nextProperties;
815
+ const [withTargets] = await withRelationTargets(db, [nextProperties]);
816
+ return withTargets;
814
817
  }
815
818
 
816
819
  function serializePropertyDefinition(
@@ -1020,6 +1023,11 @@ export async function listPropertiesForDatabaseDocuments(
1020
1023
  result.set(document.id, nextProperties);
1021
1024
  }
1022
1025
 
1026
+ const documentIdsInOrder = [...result.keys()];
1027
+ const withTargets = await withRelationTargets(db, [...result.values()]);
1028
+ documentIdsInOrder.forEach((documentId, index) => {
1029
+ result.set(documentId, withTargets[index]!);
1030
+ });
1023
1031
  return result;
1024
1032
  }
1025
1033
 
@@ -0,0 +1,291 @@
1
+ import { ActionContractError } from "@agent-native/core/action";
2
+ import { accessFilter, resolveAccess } from "@agent-native/core/sharing";
3
+ import { and, eq, exists, inArray, isNull, or } from "drizzle-orm";
4
+
5
+ import { schema } from "../server/db/index.js";
6
+ import type {
7
+ DocumentProperty,
8
+ DocumentPropertyRelationTarget,
9
+ } from "../shared/api.js";
10
+ import {
11
+ MAX_RELATION_TARGETS,
12
+ normalizeRelationIds,
13
+ parsePropertyOptions,
14
+ } from "../shared/properties.js";
15
+ import { chunks } from "./_batch-utils.js";
16
+
17
+ // Relation values are stored as a JSON array of row document IDs. Every read
18
+ // and write of them goes through this module so a canonical relationship edge
19
+ // store (docs/product/capabilities/content.relationship.edge.md) can replace
20
+ // the array later without touching callers.
21
+
22
+ type Db = any;
23
+ type ContentDatabaseRow = typeof schema.contentDatabases.$inferSelect;
24
+
25
+ function relationError(
26
+ errorCode: string,
27
+ message: string,
28
+ details?: Record<string, unknown>,
29
+ ): never {
30
+ throw new ActionContractError(message, {
31
+ errorCode,
32
+ statusCode: 400,
33
+ details,
34
+ });
35
+ }
36
+
37
+ async function liveDatabase(
38
+ db: Db,
39
+ databaseId: string,
40
+ ): Promise<ContentDatabaseRow | null> {
41
+ const [database] = await db
42
+ .select()
43
+ .from(schema.contentDatabases)
44
+ .where(
45
+ and(
46
+ eq(schema.contentDatabases.id, databaseId),
47
+ isNull(schema.contentDatabases.deletedAt),
48
+ ),
49
+ )
50
+ .limit(1);
51
+ return database ?? null;
52
+ }
53
+
54
+ async function canReadDatabase(database: ContentDatabaseRow) {
55
+ return !!(await resolveAccess("document", database.documentId));
56
+ }
57
+
58
+ /**
59
+ * Check that `targetDatabaseId` may be the target of a relation property on
60
+ * `sourceDatabase`: it must be a live, ordinary database the caller can read,
61
+ * in the same space. Linking a database to itself is allowed.
62
+ */
63
+ export async function assertRelationTargetDatabase(
64
+ db: Db,
65
+ {
66
+ sourceDatabase,
67
+ targetDatabaseId,
68
+ }: {
69
+ sourceDatabase: Pick<ContentDatabaseRow, "id" | "spaceId" | "orgId">;
70
+ targetDatabaseId: string | null | undefined;
71
+ },
72
+ ): Promise<ContentDatabaseRow> {
73
+ if (!targetDatabaseId) {
74
+ relationError("RELATION_TARGET_MISSING", "Choose a related database first");
75
+ }
76
+ const target = await liveDatabase(db, targetDatabaseId);
77
+ if (!target || target.systemRole || !(await canReadDatabase(target))) {
78
+ relationError(
79
+ "INVALID_RELATION_TARGET_DATABASE",
80
+ "The related database was not found",
81
+ );
82
+ }
83
+ if (
84
+ (target.spaceId ?? null) !== (sourceDatabase.spaceId ?? null) ||
85
+ (target.orgId ?? null) !== (sourceDatabase.orgId ?? null)
86
+ ) {
87
+ relationError(
88
+ "RELATION_TARGET_OTHER_SPACE",
89
+ "A relation can only link to a database in the same space",
90
+ );
91
+ }
92
+ return target;
93
+ }
94
+
95
+ /**
96
+ * Resolve linked row IDs to titles the current viewer may see. A row is
97
+ * visible when the viewer can read the row itself, or can read the target
98
+ * database and the row is a live member of it. Unknown, trashed and
99
+ * restricted rows are simply absent from the result.
100
+ */
101
+ export async function resolveRelationTargets(
102
+ db: Db,
103
+ {
104
+ targetDatabaseId,
105
+ documentIds,
106
+ }: { targetDatabaseId: string; documentIds: string[] },
107
+ ): Promise<Map<string, DocumentPropertyRelationTarget>> {
108
+ const result = new Map<string, DocumentPropertyRelationTarget>();
109
+ const ids = normalizeRelationIds(documentIds);
110
+ if (ids.length === 0) return result;
111
+ const target = await liveDatabase(db, targetDatabaseId);
112
+ if (!target || target.systemRole) return result;
113
+ const targetReadable = await canReadDatabase(target);
114
+ const membership = exists(
115
+ db
116
+ .select({ id: schema.contentDatabaseItems.id })
117
+ .from(schema.contentDatabaseItems)
118
+ .where(
119
+ and(
120
+ eq(schema.contentDatabaseItems.databaseId, target.id),
121
+ eq(schema.contentDatabaseItems.documentId, schema.documents.id),
122
+ ),
123
+ ),
124
+ );
125
+ const visible = accessFilter(schema.documents, schema.documentShares);
126
+
127
+ for (const chunk of chunks(ids, 200)) {
128
+ const rows = await db
129
+ .select({
130
+ id: schema.documents.id,
131
+ title: schema.documents.title,
132
+ icon: schema.documents.icon,
133
+ })
134
+ .from(schema.documents)
135
+ .where(
136
+ and(
137
+ inArray(schema.documents.id, chunk),
138
+ isNull(schema.documents.trashedAt),
139
+ targetReadable ? or(visible, membership) : visible,
140
+ ),
141
+ );
142
+ for (const row of rows) {
143
+ result.set(row.id, {
144
+ documentId: row.id,
145
+ title: row.title || "Untitled",
146
+ icon: row.icon ?? null,
147
+ databaseId: targetReadable ? target.id : null,
148
+ databaseDocumentId: targetReadable ? target.documentId : null,
149
+ });
150
+ }
151
+ }
152
+ return result;
153
+ }
154
+
155
+ /** The relation target database configured on a property definition. */
156
+ export function relationTargetDatabaseId(definition: {
157
+ optionsJson?: string | null;
158
+ }): string | null {
159
+ return (
160
+ parsePropertyOptions(definition.optionsJson).relation?.databaseId ?? null
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Validate and normalize a relation value before it is stored. IDs that were
166
+ * already stored are kept without re-checking, so an editor who cannot see
167
+ * some links does not silently drop them. Newly added IDs must be live rows of
168
+ * the target database, which the caller must be able to read.
169
+ */
170
+ export async function validateRelationWrite(
171
+ db: Db,
172
+ {
173
+ definition,
174
+ nextValue,
175
+ previousValue,
176
+ }: {
177
+ definition: { optionsJson?: string | null };
178
+ nextValue: unknown;
179
+ previousValue: unknown;
180
+ },
181
+ ): Promise<string[] | null> {
182
+ const targetDatabaseId = relationTargetDatabaseId(definition);
183
+ if (!targetDatabaseId) {
184
+ relationError("RELATION_TARGET_MISSING", "Choose a related database first");
185
+ }
186
+ const ids = normalizeRelationIds(nextValue);
187
+ if (ids.length === 0) return null;
188
+ if (ids.length > MAX_RELATION_TARGETS) {
189
+ relationError(
190
+ "RELATION_LIMIT_EXCEEDED",
191
+ `A relation can link to at most ${MAX_RELATION_TARGETS} pages`,
192
+ );
193
+ }
194
+ const previous = new Set(normalizeRelationIds(previousValue));
195
+ const added = ids.filter((id) => !previous.has(id));
196
+ if (added.length === 0) return ids;
197
+
198
+ const target = await liveDatabase(db, targetDatabaseId);
199
+ if (
200
+ !target ||
201
+ target.systemRole ||
202
+ !(await resolveAccess("document", target.documentId))
203
+ ) {
204
+ relationError(
205
+ "INVALID_RELATION_TARGET",
206
+ "The related database is not available",
207
+ { invalidIds: added },
208
+ );
209
+ }
210
+ const found = new Set<string>();
211
+ for (const chunk of chunks(added, 200)) {
212
+ const rows = await db
213
+ .select({ id: schema.documents.id })
214
+ .from(schema.documents)
215
+ .innerJoin(
216
+ schema.contentDatabaseItems,
217
+ eq(schema.contentDatabaseItems.documentId, schema.documents.id),
218
+ )
219
+ .where(
220
+ and(
221
+ inArray(schema.documents.id, chunk),
222
+ eq(schema.contentDatabaseItems.databaseId, target.id),
223
+ isNull(schema.documents.trashedAt),
224
+ ),
225
+ );
226
+ for (const row of rows) found.add(row.id);
227
+ }
228
+ const invalidIds = added.filter((id) => !found.has(id));
229
+ if (invalidIds.length > 0) {
230
+ relationError(
231
+ "INVALID_RELATION_TARGET",
232
+ "Some linked pages are not rows of the related database",
233
+ { invalidIds },
234
+ );
235
+ }
236
+ return ids;
237
+ }
238
+
239
+ /**
240
+ * Attach `relationTargets` to every relation property in `propertyLists`
241
+ * (one list per row). Linked rows are resolved with one lookup per target
242
+ * database across all rows, so a table view does not query per cell.
243
+ */
244
+ export async function withRelationTargets(
245
+ db: Db,
246
+ propertyLists: DocumentProperty[][],
247
+ ): Promise<DocumentProperty[][]> {
248
+ const idsByTarget = new Map<string, Set<string>>();
249
+ for (const properties of propertyLists) {
250
+ for (const property of properties) {
251
+ if (property.definition.type !== "relation") continue;
252
+ const targetId = property.definition.options.relation?.databaseId;
253
+ if (!targetId) continue;
254
+ const ids = normalizeRelationIds(property.value);
255
+ if (ids.length === 0) continue;
256
+ let set = idsByTarget.get(targetId);
257
+ if (!set) idsByTarget.set(targetId, (set = new Set()));
258
+ for (const id of ids) set.add(id);
259
+ }
260
+ }
261
+ if (idsByTarget.size === 0) return propertyLists;
262
+
263
+ const resolvedByTarget = new Map<
264
+ string,
265
+ Map<string, DocumentPropertyRelationTarget>
266
+ >();
267
+ for (const [targetDatabaseId, ids] of idsByTarget) {
268
+ resolvedByTarget.set(
269
+ targetDatabaseId,
270
+ await resolveRelationTargets(db, {
271
+ targetDatabaseId,
272
+ documentIds: [...ids],
273
+ }),
274
+ );
275
+ }
276
+
277
+ return propertyLists.map((properties) =>
278
+ properties.map((property) => {
279
+ if (property.definition.type !== "relation") return property;
280
+ const targetId = property.definition.options.relation?.databaseId;
281
+ const resolved = targetId ? resolvedByTarget.get(targetId) : undefined;
282
+ return {
283
+ ...property,
284
+ relationTargets: normalizeRelationIds(property.value).flatMap((id) => {
285
+ const target = resolved?.get(id);
286
+ return target ? [target] : [];
287
+ }),
288
+ };
289
+ }),
290
+ );
291
+ }
@@ -1,3 +1,4 @@
1
+ import { ActionContractError } from "@agent-native/core";
1
2
  import { defineAction } from "@agent-native/core/action";
2
3
  import { writeAppState } from "@agent-native/core/application-state";
3
4
  import { iconValueSchema, serializeIconValue } from "@agent-native/core/icons";
@@ -40,6 +41,7 @@ import {
40
41
  optionsForNewProperty,
41
42
  resolvePropertyDatabaseForDocument,
42
43
  } from "./_property-utils.js";
44
+ import { assertRelationTargetDatabase } from "./_relation-values.js";
43
45
 
44
46
  const legacyConfigureDocumentPropertySchema = z
45
47
  .object({
@@ -157,7 +159,7 @@ export default defineAction({
157
159
  const name = args.name.trim();
158
160
  const type = args.type as DocumentPropertyType;
159
161
  const propertyId = args.id ?? nanoid();
160
- const optionsJson = optionsForNewProperty(type, args.options as any);
162
+ let optionsJson = optionsForNewProperty(type, args.options as any);
161
163
  const database = await resolvePropertyDatabaseForDocument(
162
164
  document,
163
165
  args.databaseId,
@@ -168,6 +170,42 @@ export default defineAction({
168
170
  "Properties belong to databases. Create or open a database before adding properties.",
169
171
  );
170
172
  }
173
+ let requestedRelationTarget = args.options?.relation?.databaseId;
174
+ if (type === "relation" && !requestedRelationTarget && args.id) {
175
+ // Metadata-only updates (rename, visibility) keep the existing target.
176
+ const [current] = await db
177
+ .select({
178
+ type: schema.documentPropertyDefinitions.type,
179
+ optionsJson: schema.documentPropertyDefinitions.optionsJson,
180
+ })
181
+ .from(schema.documentPropertyDefinitions)
182
+ .where(
183
+ and(
184
+ eq(schema.documentPropertyDefinitions.id, args.id),
185
+ eq(
186
+ schema.documentPropertyDefinitions.ownerEmail,
187
+ document.ownerEmail,
188
+ ),
189
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
190
+ ),
191
+ );
192
+ if (current?.type === "relation") {
193
+ requestedRelationTarget = parsePropertyOptions(current.optionsJson)
194
+ .relation?.databaseId;
195
+ }
196
+ }
197
+ const relationTarget =
198
+ type === "relation"
199
+ ? await assertRelationTargetDatabase(db, {
200
+ sourceDatabase: database,
201
+ targetDatabaseId: requestedRelationTarget,
202
+ })
203
+ : null;
204
+ if (relationTarget) {
205
+ optionsJson = serializePropertyOptions({
206
+ relation: { databaseId: relationTarget.id },
207
+ });
208
+ }
171
209
  if (args.naturalKey === true && type !== "text") {
172
210
  throw new Error(
173
211
  "A database natural key must be an ordinary text property.",
@@ -240,6 +278,20 @@ export default defineAction({
240
278
  const lockedOptions = parsePropertyOptions(
241
279
  lockedDefinition.optionsJson,
242
280
  );
281
+ const lockedRelationTarget =
282
+ lockedDefinition.type === "relation"
283
+ ? (lockedOptions.relation?.databaseId ?? null)
284
+ : null;
285
+ if (
286
+ relationTarget &&
287
+ lockedRelationTarget &&
288
+ lockedRelationTarget !== relationTarget.id
289
+ ) {
290
+ throw new ActionContractError(
291
+ "Create a new relation property to link a different database.",
292
+ { errorCode: "RELATION_TARGET_IMMUTABLE", statusCode: 400 },
293
+ );
294
+ }
243
295
  const lockedIsPrimaryBlocks =
244
296
  isBlocksPropertyType(lockedDefinition.type as DocumentPropertyType) &&
245
297
  isPrimaryBlocksField(lockedOptions);