@agent-native/core 0.197.0-nightly-20260929180759 → 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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +10 -0
- package/corpus/templates/assets/CHANGELOG.md +8 -0
- package/corpus/templates/calendar/CHANGELOG.md +18 -0
- package/corpus/templates/clips/CHANGELOG.md +19 -0
- package/corpus/templates/content/CHANGELOG.md +16 -0
- package/corpus/templates/content/actions/_database-property-setup.ts +33 -2
- package/corpus/templates/content/actions/_database-setup-discovery.ts +4 -4
- package/corpus/templates/content/actions/_property-utils.ts +9 -1
- package/corpus/templates/content/actions/_relation-values.ts +291 -0
- package/corpus/templates/content/actions/configure-document-property.ts +53 -1
- package/corpus/templates/content/actions/search-content-database-rows.ts +124 -0
- package/corpus/templates/content/actions/set-document-property.ts +21 -1
- package/corpus/templates/content/agent-native.config.ts +5 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +892 -189
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -2
- package/corpus/templates/content/app/components/editor/database/navigation-state.ts +4 -1
- package/corpus/templates/content/app/hooks/use-content-database.ts +16 -2
- package/corpus/templates/content/app/hooks/use-document-properties.ts +65 -5
- package/corpus/templates/content/app/i18n-data.ts +28 -0
- package/corpus/templates/content/changelog/2026-09-28-link-rows-across-collections-with-relation-properties.md +6 -0
- package/corpus/templates/content/changelog/2026-09-29-related-pages-can-be-loaded-past-the-first-25-results.md +6 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +1 -0
- package/corpus/templates/content/shared/api.ts +21 -0
- package/corpus/templates/content/shared/properties.ts +19 -0
- package/corpus/templates/crm/CHANGELOG.md +8 -0
- package/corpus/templates/design/CHANGELOG.md +33 -0
- package/corpus/templates/design/actions/clone-creative-context-design-native.ts +12 -3
- package/corpus/templates/design/actions/generate-design.ts +2 -29
- package/corpus/templates/design/actions/update-design.ts +33 -8
- package/corpus/templates/design/app/components/design/design-canvas/LocalNetworkAccessPrompt.tsx +2 -2
- package/corpus/templates/design/changelog/2026-09-29-design-tweak-controls-stay-intact-when-an-edit-contains-inva.md +6 -0
- package/corpus/templates/design/changelog/2026-09-29-the-chrome-fallback-guide-shows-the-local-network-allow-opti.md +6 -0
- package/corpus/templates/design/shared/tweak-definition-schema.ts +30 -0
- package/corpus/templates/dispatch/CHANGELOG.md +4 -0
- package/corpus/templates/mail/CHANGELOG.md +34 -0
- package/corpus/templates/mail/app/components/email/EmailList.tsx +29 -11
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +4 -4
- package/corpus/templates/mail/app/pages/InboxPage.tsx +34 -10
- package/corpus/templates/mail/changelog/2026-09-29-mail-loading-layout.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +19 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/AppShellSkeleton.js +97 -54
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +7 -7
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/dist/triggers/actions/manage-automation.d.ts +2 -2
- package/docs/content/versioning-and-stability.mdx +3 -2
- package/package.json +4 -4
package/corpus/README.md
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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: [...
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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);
|