@notionhq/apps 0.0.17 → 0.0.18
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/AGENTS.md +27 -0
- package/README.md +60 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/notion-as-code/database.d.ts +88 -48
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +132 -45
- package/dist/notion-as-code/database.test.d.ts +2 -0
- package/dist/notion-as-code/database.test.d.ts.map +1 -0
- package/dist/notion-as-code/index.d.ts +2 -2
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/intents.d.ts +34 -2
- package/dist/notion-as-code/intents.d.ts.map +1 -1
- package/dist/notion-as-code/schema.d.ts +8 -4
- package/dist/notion-as-code/schema.d.ts.map +1 -1
- package/dist/notion-as-code/views.d.ts +29 -10
- package/dist/notion-as-code/views.d.ts.map +1 -1
- package/dist/sync.d.ts +33 -15
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +12 -0
- package/dist/workflow-state.d.ts +13 -0
- package/dist/workflow-state.d.ts.map +1 -0
- package/dist/workflow-state.js +107 -0
- package/dist/workflow.d.ts +48 -3
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +111 -4
- package/docs/BUILD.md +96 -0
- package/package.json +1 -1
- package/skills/notion-as-code/SKILL.md +81 -49
- package/skills/sync/SKILL.md +21 -18
- package/skills/workflow/SKILL.md +52 -3
- package/src/cli/build.test.ts +124 -64
- package/src/index.ts +1 -0
- package/src/notion-as-code/database.test.ts +661 -0
- package/src/notion-as-code/database.ts +344 -127
- package/src/notion-as-code/index.ts +16 -1
- package/src/notion-as-code/intents.ts +39 -2
- package/src/notion-as-code/schema.ts +11 -3
- package/src/notion-as-code/views.ts +29 -10
- package/src/sync.test.ts +295 -0
- package/src/sync.ts +85 -21
- package/src/workflow-state.ts +152 -0
- package/src/workflow.test.ts +329 -1
- package/src/workflow.ts +180 -6
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Builder } from "../builder.js";
|
|
4
|
+
import { ExecutionError } from "../error.js";
|
|
5
|
+
import { sync } from "../sync.js";
|
|
6
|
+
import { notion, type DataSourceHandle, type SingleSourceDatabaseHandle } from "./index.js";
|
|
7
|
+
import { APPS_WORKSPACE_RESOURCE_ID } from "./intents.js";
|
|
8
|
+
import { finishMetadataRecording, startMetadataRecording } from "./recorder.js";
|
|
9
|
+
|
|
10
|
+
describe("Notion-as-Code database declarations", () => {
|
|
11
|
+
it("converts the single-source form with keyed schemas and resolved property names", () => {
|
|
12
|
+
const recorder = startMetadataRecording();
|
|
13
|
+
const schema = {
|
|
14
|
+
Title: { type: "title", resourceId: "single-title-id" },
|
|
15
|
+
Estimate: { type: "number", resourceId: "single-estimate-id" },
|
|
16
|
+
State: {
|
|
17
|
+
type: "status",
|
|
18
|
+
resourceId: "single-state-id",
|
|
19
|
+
options: {
|
|
20
|
+
todo: [{ name: "Not started", color: "gray" }],
|
|
21
|
+
inProgress: [{ name: "Doing", color: "blue", default: true }],
|
|
22
|
+
complete: [{ name: "Done", color: "green" }],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
const database = notion.database("single-database-id", {
|
|
27
|
+
dataSourceResourceId: "single-source-id",
|
|
28
|
+
name: "Tasks",
|
|
29
|
+
schema,
|
|
30
|
+
});
|
|
31
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
32
|
+
|
|
33
|
+
expectTypeOf(database).toExtend<SingleSourceDatabaseHandle<typeof schema>>();
|
|
34
|
+
expectTypeOf(database.dataSource).toExtend<DataSourceHandle<typeof schema>>();
|
|
35
|
+
expectTypeOf(database.dataSource.schema.Title.type).toEqualTypeOf<"title">();
|
|
36
|
+
expectTypeOf(
|
|
37
|
+
database.dataSource.schema.Title.resourceId,
|
|
38
|
+
).toEqualTypeOf<"single-title-id">();
|
|
39
|
+
expect(database.dataSource.schema).toEqual(schema);
|
|
40
|
+
|
|
41
|
+
const declaration = sync({
|
|
42
|
+
dataSource: database.dataSource,
|
|
43
|
+
primaryKey: "Title",
|
|
44
|
+
handler: async () => ({ changes: [] }),
|
|
45
|
+
});
|
|
46
|
+
expect(declaration.config.database.key).toBe("single-source-id");
|
|
47
|
+
expect(declaration.config.primaryKeyProperty).toBe("Title");
|
|
48
|
+
const invalidSyncDeclarations = () => {
|
|
49
|
+
sync({
|
|
50
|
+
dataSource: database.dataSource,
|
|
51
|
+
// @ts-expect-error Number properties cannot be sync primary keys.
|
|
52
|
+
primaryKey: "Estimate",
|
|
53
|
+
handler: async () => ({ changes: [] }),
|
|
54
|
+
});
|
|
55
|
+
sync({
|
|
56
|
+
dataSource: database.dataSource,
|
|
57
|
+
// @ts-expect-error Unknown properties cannot be used as sync primary keys.
|
|
58
|
+
primaryKey: "Unknown",
|
|
59
|
+
handler: async () => ({ changes: [] }),
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
void invalidSyncDeclarations;
|
|
63
|
+
|
|
64
|
+
expect(intents).toEqual([
|
|
65
|
+
{
|
|
66
|
+
type: "database",
|
|
67
|
+
resourceId: "single-database-id",
|
|
68
|
+
name: "Tasks",
|
|
69
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
70
|
+
dataSources: [
|
|
71
|
+
{
|
|
72
|
+
resourceId: "single-source-id",
|
|
73
|
+
name: "Tasks",
|
|
74
|
+
properties: [
|
|
75
|
+
{ type: "title", resourceId: "single-title-id", name: "Title" },
|
|
76
|
+
{ type: "number", resourceId: "single-estimate-id", name: "Estimate" },
|
|
77
|
+
{
|
|
78
|
+
type: "status",
|
|
79
|
+
resourceId: "single-state-id",
|
|
80
|
+
name: "State",
|
|
81
|
+
options: {
|
|
82
|
+
todo: [{ name: "Not started", color: "gray" }],
|
|
83
|
+
inProgress: [{ name: "Doing", color: "blue", default: true }],
|
|
84
|
+
complete: [{ name: "Done", color: "green" }],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("accepts and executes partial upsert properties for Notion-as-Code sources", async () => {
|
|
95
|
+
const database = notion.database("partial-sync-database", {
|
|
96
|
+
dataSourceResourceId: "partial-sync-source",
|
|
97
|
+
name: "Meetings",
|
|
98
|
+
schema: {
|
|
99
|
+
Name: { resourceId: "partial-sync-name", type: "title" },
|
|
100
|
+
"Meeting ID": { resourceId: "partial-sync-id", type: "text" },
|
|
101
|
+
Other: { resourceId: "partial-sync-other", type: "text" },
|
|
102
|
+
"Meeting date": { resourceId: "partial-sync-date", type: "date" },
|
|
103
|
+
"Prep complete": { resourceId: "partial-sync-prep", type: "checkbox" },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const valid = sync({
|
|
108
|
+
dataSource: database.dataSource,
|
|
109
|
+
primaryKey: "Meeting ID",
|
|
110
|
+
handler: async (state: { cursor: number } | undefined) => ({
|
|
111
|
+
changes: [
|
|
112
|
+
{
|
|
113
|
+
type: "upsert",
|
|
114
|
+
key: "meeting-1",
|
|
115
|
+
properties: { Name: Builder.title("One") },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: "upsert",
|
|
119
|
+
key: "meeting-2",
|
|
120
|
+
properties: { "Prep complete": Builder.checkbox(true) },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "upsert",
|
|
124
|
+
key: "meeting-3",
|
|
125
|
+
properties: { Other: Builder.text("Three") },
|
|
126
|
+
},
|
|
127
|
+
{ type: "delete", key: "meeting-4" },
|
|
128
|
+
],
|
|
129
|
+
nextState: { cursor: (state?.cursor ?? 0) + 1 },
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
const result = await valid.handler({ state: { cursor: 4 } }, { concreteOutput: true });
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
changes: [
|
|
135
|
+
{
|
|
136
|
+
type: "upsert",
|
|
137
|
+
key: "meeting-1",
|
|
138
|
+
properties: {
|
|
139
|
+
Name: [["One"]],
|
|
140
|
+
"Meeting ID": [["meeting-1"]],
|
|
141
|
+
},
|
|
142
|
+
targetDatabaseKey: "partial-sync-source",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "upsert",
|
|
146
|
+
key: "meeting-2",
|
|
147
|
+
properties: {
|
|
148
|
+
"Prep complete": [["Yes"]],
|
|
149
|
+
"Meeting ID": [["meeting-2"]],
|
|
150
|
+
},
|
|
151
|
+
targetDatabaseKey: "partial-sync-source",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: "upsert",
|
|
155
|
+
key: "meeting-3",
|
|
156
|
+
properties: {
|
|
157
|
+
Other: [["Three"]],
|
|
158
|
+
"Meeting ID": [["meeting-3"]],
|
|
159
|
+
},
|
|
160
|
+
targetDatabaseKey: "partial-sync-source",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
type: "delete",
|
|
164
|
+
key: "meeting-4",
|
|
165
|
+
targetDatabaseKey: "partial-sync-source",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
hasMore: false,
|
|
169
|
+
nextUserContext: { cursor: 5 },
|
|
170
|
+
nextPacerStates: {},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const invalidRuntimeUpsert = sync({
|
|
174
|
+
dataSource: database.dataSource,
|
|
175
|
+
primaryKey: "Meeting ID",
|
|
176
|
+
handler: async () => ({
|
|
177
|
+
changes: [
|
|
178
|
+
{
|
|
179
|
+
type: "upsert",
|
|
180
|
+
key: "meeting-empty",
|
|
181
|
+
properties: {},
|
|
182
|
+
icon: Builder.notionIcon("warning"),
|
|
183
|
+
cover: Builder.imageCover("https://example.com/empty-cover.png"),
|
|
184
|
+
pageContentMarkdown: "# Empty properties",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
} as never);
|
|
189
|
+
await expect(
|
|
190
|
+
invalidRuntimeUpsert.handler(undefined, { concreteOutput: true }),
|
|
191
|
+
).rejects.toBeInstanceOf(ExecutionError);
|
|
192
|
+
|
|
193
|
+
const invalidSyncDeclarations = () => {
|
|
194
|
+
const finiteProperties = {
|
|
195
|
+
Name: Builder.title("Prepared"),
|
|
196
|
+
Unknown: Builder.text("unexpected"),
|
|
197
|
+
};
|
|
198
|
+
const spreadProperties = {
|
|
199
|
+
Unknown: Builder.text("unexpected from spread"),
|
|
200
|
+
};
|
|
201
|
+
sync({
|
|
202
|
+
dataSource: database.dataSource,
|
|
203
|
+
primaryKey: "Meeting ID",
|
|
204
|
+
// @ts-expect-error Upsert properties must include a non-primary-key schema property.
|
|
205
|
+
handler: async () => ({
|
|
206
|
+
changes: [
|
|
207
|
+
{
|
|
208
|
+
type: "upsert",
|
|
209
|
+
key: "invalid-empty",
|
|
210
|
+
properties: {},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
sync({
|
|
216
|
+
dataSource: database.dataSource,
|
|
217
|
+
primaryKey: "Meeting ID",
|
|
218
|
+
// @ts-expect-error Unknown property keys are rejected in inferred handler returns.
|
|
219
|
+
handler: async () => ({
|
|
220
|
+
changes: [
|
|
221
|
+
{
|
|
222
|
+
type: "upsert",
|
|
223
|
+
key: "invalid-inline",
|
|
224
|
+
properties: {
|
|
225
|
+
Name: Builder.title("Known"),
|
|
226
|
+
Unknown: Builder.text("unexpected"),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
sync({
|
|
233
|
+
dataSource: database.dataSource,
|
|
234
|
+
primaryKey: "Meeting ID",
|
|
235
|
+
// @ts-expect-error Finite property objects cannot contain unknown keys.
|
|
236
|
+
handler: async () => ({
|
|
237
|
+
changes: [
|
|
238
|
+
{
|
|
239
|
+
type: "upsert",
|
|
240
|
+
key: "invalid-finite",
|
|
241
|
+
properties: finiteProperties,
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
sync({
|
|
247
|
+
dataSource: database.dataSource,
|
|
248
|
+
primaryKey: "Meeting ID",
|
|
249
|
+
// @ts-expect-error Spread property objects cannot contain unknown keys.
|
|
250
|
+
handler: async () => ({
|
|
251
|
+
changes: [
|
|
252
|
+
{
|
|
253
|
+
type: "upsert",
|
|
254
|
+
key: "invalid-spread",
|
|
255
|
+
properties: {
|
|
256
|
+
Name: Builder.title("Known from spread"),
|
|
257
|
+
...spreadProperties,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
sync({
|
|
264
|
+
dataSource: database.dataSource,
|
|
265
|
+
primaryKey: "Meeting ID",
|
|
266
|
+
// @ts-expect-error Known properties must use their declared value types.
|
|
267
|
+
handler: async () => ({
|
|
268
|
+
changes: [
|
|
269
|
+
{
|
|
270
|
+
type: "upsert",
|
|
271
|
+
key: "invalid-value",
|
|
272
|
+
properties: {
|
|
273
|
+
Name: Builder.people("person@example.com"),
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
sync({
|
|
280
|
+
dataSource: database.dataSource,
|
|
281
|
+
primaryKey: "Meeting ID",
|
|
282
|
+
// @ts-expect-error The primary-key property is supplied from change.key.
|
|
283
|
+
handler: async () => ({
|
|
284
|
+
changes: [
|
|
285
|
+
{
|
|
286
|
+
type: "upsert",
|
|
287
|
+
key: "invalid-primary-property",
|
|
288
|
+
properties: {
|
|
289
|
+
"Meeting ID": Builder.text("explicit"),
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
sync({
|
|
296
|
+
dataSource: database.dataSource,
|
|
297
|
+
// @ts-expect-error Unknown schema properties cannot be sync primary keys.
|
|
298
|
+
primaryKey: "Unknown",
|
|
299
|
+
handler: async () => ({ changes: [] }),
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
void invalidSyncDeclarations;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("converts keyed multi-source definitions and discriminates source handles", () => {
|
|
306
|
+
const recorder = startMetadataRecording();
|
|
307
|
+
const taskSchema = {
|
|
308
|
+
Title: { type: "title", resourceId: "tasks-title-id" },
|
|
309
|
+
Estimate: { type: "text", resourceId: "tasks-estimate-id" },
|
|
310
|
+
} as const;
|
|
311
|
+
const archiveSchema = {
|
|
312
|
+
Title: { type: "title", resourceId: "archive-title-id" },
|
|
313
|
+
ArchiveOnly: { type: "text", resourceId: "archive-only-id" },
|
|
314
|
+
} as const;
|
|
315
|
+
const database = notion.database("multi-database-id", {
|
|
316
|
+
name: "Project DB",
|
|
317
|
+
datasources: {
|
|
318
|
+
Tasks: {
|
|
319
|
+
resourceId: "tasks-source-id",
|
|
320
|
+
schema: taskSchema,
|
|
321
|
+
},
|
|
322
|
+
"Archived Tasks": {
|
|
323
|
+
resourceId: "archive-source-id",
|
|
324
|
+
schema: archiveSchema,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
329
|
+
|
|
330
|
+
expectTypeOf(database.datasources.Tasks).toExtend<DataSourceHandle<typeof taskSchema>>();
|
|
331
|
+
expectTypeOf(database.datasources.Tasks.schema.Title.type).toEqualTypeOf<"title">();
|
|
332
|
+
expectTypeOf(
|
|
333
|
+
database.datasources.Tasks.schema.Estimate.resourceId,
|
|
334
|
+
).toEqualTypeOf<"tasks-estimate-id">();
|
|
335
|
+
expectTypeOf(database.datasources["Archived Tasks"]).toExtend<
|
|
336
|
+
DataSourceHandle<typeof archiveSchema>
|
|
337
|
+
>();
|
|
338
|
+
expectTypeOf(
|
|
339
|
+
database.datasources["Archived Tasks"].schema.ArchiveOnly.type,
|
|
340
|
+
).toEqualTypeOf<"text">();
|
|
341
|
+
expect(database.datasources.Tasks.schema).toEqual(taskSchema);
|
|
342
|
+
expect(database.datasources["Archived Tasks"].schema).toEqual(archiveSchema);
|
|
343
|
+
|
|
344
|
+
const archiveSync = sync({
|
|
345
|
+
dataSource: database.datasources["Archived Tasks"],
|
|
346
|
+
primaryKey: "ArchiveOnly",
|
|
347
|
+
handler: async () => ({ changes: [] }),
|
|
348
|
+
});
|
|
349
|
+
expect(archiveSync.config.database.key).toBe("archive-source-id");
|
|
350
|
+
const invalidCrossSourceSync = () => {
|
|
351
|
+
sync({
|
|
352
|
+
dataSource: database.datasources.Tasks,
|
|
353
|
+
// @ts-expect-error A primary key from another source is not accepted.
|
|
354
|
+
primaryKey: "ArchiveOnly",
|
|
355
|
+
handler: async () => ({ changes: [] }),
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
void invalidCrossSourceSync;
|
|
359
|
+
|
|
360
|
+
expect(intents).toEqual([
|
|
361
|
+
{
|
|
362
|
+
type: "database",
|
|
363
|
+
resourceId: "multi-database-id",
|
|
364
|
+
name: "Project DB",
|
|
365
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
366
|
+
dataSources: [
|
|
367
|
+
{
|
|
368
|
+
resourceId: "tasks-source-id",
|
|
369
|
+
name: "Tasks",
|
|
370
|
+
properties: [
|
|
371
|
+
{ type: "title", resourceId: "tasks-title-id", name: "Title" },
|
|
372
|
+
{ type: "text", resourceId: "tasks-estimate-id", name: "Estimate" },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
resourceId: "archive-source-id",
|
|
377
|
+
name: "Archived Tasks",
|
|
378
|
+
properties: [
|
|
379
|
+
{ type: "title", resourceId: "archive-title-id", name: "Title" },
|
|
380
|
+
{ type: "text", resourceId: "archive-only-id", name: "ArchiveOnly" },
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
},
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("rejects data-source name overrides, including predeclared maps", () => {
|
|
389
|
+
const predeclaredDatasources = {
|
|
390
|
+
Tasks: {
|
|
391
|
+
resourceId: "predeclared-source-id",
|
|
392
|
+
name: "Legacy source name",
|
|
393
|
+
schema: {
|
|
394
|
+
Title: { type: "title", resourceId: "predeclared-title-id" },
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
} as const;
|
|
398
|
+
const invalidDeclarations = () => {
|
|
399
|
+
// @ts-expect-error Data-source names are supplied by map keys.
|
|
400
|
+
notion.database("inline-data-source-name-override", {
|
|
401
|
+
datasources: {
|
|
402
|
+
Tasks: {
|
|
403
|
+
resourceId: "inline-source-id",
|
|
404
|
+
name: "Legacy source name",
|
|
405
|
+
schema: {
|
|
406
|
+
Title: { type: "title", resourceId: "inline-title-id" },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
notion.database("predeclared-data-source-name-override", {
|
|
412
|
+
// @ts-expect-error Predeclared maps cannot override keyed source names.
|
|
413
|
+
datasources: predeclaredDatasources,
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
void invalidDeclarations;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("normalizes keyed source and property names despite legacy overrides", () => {
|
|
420
|
+
const recorder = startMetadataRecording();
|
|
421
|
+
notion.database("runtime-name-override", {
|
|
422
|
+
name: "Runtime",
|
|
423
|
+
datasources: {
|
|
424
|
+
"Runtime Source": {
|
|
425
|
+
resourceId: "runtime-source-id",
|
|
426
|
+
name: "Legacy source name",
|
|
427
|
+
schema: {
|
|
428
|
+
"Runtime Title": {
|
|
429
|
+
type: "title",
|
|
430
|
+
resourceId: "runtime-title-id",
|
|
431
|
+
name: "Legacy property name",
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
} as never);
|
|
437
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
438
|
+
|
|
439
|
+
expect(intents).toEqual([
|
|
440
|
+
{
|
|
441
|
+
type: "database",
|
|
442
|
+
resourceId: "runtime-name-override",
|
|
443
|
+
name: "Runtime",
|
|
444
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
445
|
+
dataSources: [
|
|
446
|
+
{
|
|
447
|
+
resourceId: "runtime-source-id",
|
|
448
|
+
name: "Runtime Source",
|
|
449
|
+
properties: [
|
|
450
|
+
{
|
|
451
|
+
type: "title",
|
|
452
|
+
resourceId: "runtime-title-id",
|
|
453
|
+
name: "Runtime Title",
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("rejects property name overrides, including predeclared schemas", () => {
|
|
463
|
+
const predeclaredSchema = {
|
|
464
|
+
Name: { type: "title", resourceId: "predeclared-name-id", name: "Display name" },
|
|
465
|
+
} as const;
|
|
466
|
+
const invalidDeclarations = () => {
|
|
467
|
+
notion.database("inline-name-override", {
|
|
468
|
+
dataSourceResourceId: "inline-name-source",
|
|
469
|
+
name: "Invalid",
|
|
470
|
+
schema: {
|
|
471
|
+
// @ts-expect-error Property names are supplied by schema keys.
|
|
472
|
+
Name: { type: "title", resourceId: "inline-name-id", name: "Display name" },
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
notion.database("predeclared-name-override", {
|
|
476
|
+
dataSourceResourceId: "predeclared-name-source",
|
|
477
|
+
name: "Invalid",
|
|
478
|
+
// @ts-expect-error Predeclared schemas cannot override keyed property names.
|
|
479
|
+
schema: predeclaredSchema,
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
void invalidDeclarations;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("rejects mixed single and multi-source arguments before recording", () => {
|
|
486
|
+
const recorder = startMetadataRecording();
|
|
487
|
+
const mixed = {
|
|
488
|
+
dataSourceResourceId: "mixed-source-id",
|
|
489
|
+
name: "Mixed",
|
|
490
|
+
schema: {
|
|
491
|
+
Name: { type: "title", resourceId: "mixed-title-id" },
|
|
492
|
+
},
|
|
493
|
+
datasources: {
|
|
494
|
+
Other: {
|
|
495
|
+
resourceId: "other-source-id",
|
|
496
|
+
schema: {
|
|
497
|
+
Name: { type: "title", resourceId: "other-title-id" },
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
} as const;
|
|
502
|
+
const typedMixedDeclaration = () => {
|
|
503
|
+
// @ts-expect-error Single and multi-source forms are mutually exclusive.
|
|
504
|
+
notion.database("mixed-database-id", mixed);
|
|
505
|
+
};
|
|
506
|
+
void typedMixedDeclaration;
|
|
507
|
+
|
|
508
|
+
expect(() => notion.database("mixed-database-id", mixed as never)).toThrow();
|
|
509
|
+
expect(finishMetadataRecording(recorder).intents).toEqual([]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("rejects known empty databases statically and at runtime", () => {
|
|
513
|
+
const emptySources = {};
|
|
514
|
+
const invalidEmptyDeclarations = () => {
|
|
515
|
+
// @ts-expect-error A predeclared empty source map needs a source or a view.
|
|
516
|
+
notion.database("empty-predeclared-datasources", { datasources: emptySources });
|
|
517
|
+
// @ts-expect-error A multi-source database needs a source or a view.
|
|
518
|
+
notion.database("empty-datasources", { datasources: {} });
|
|
519
|
+
// @ts-expect-error A database needs a source or a view.
|
|
520
|
+
notion.database("empty-arguments", {});
|
|
521
|
+
// @ts-expect-error Empty sources and views are not a valid database.
|
|
522
|
+
notion.database("empty-with-views", { datasources: {}, views: [] });
|
|
523
|
+
};
|
|
524
|
+
void invalidEmptyDeclarations;
|
|
525
|
+
|
|
526
|
+
const dynamicDatasources: Record<string, never> = {};
|
|
527
|
+
const dynamicViews: never[] = [];
|
|
528
|
+
expect(() =>
|
|
529
|
+
notion.database("runtime-empty-datasources", { datasources: {} } as never),
|
|
530
|
+
).toThrow();
|
|
531
|
+
expect(() => notion.database("runtime-empty-arguments", {} as never)).toThrow();
|
|
532
|
+
expect(() =>
|
|
533
|
+
notion.database("runtime-empty-with-views", { datasources: {}, views: [] } as never),
|
|
534
|
+
).toThrow();
|
|
535
|
+
expect(() =>
|
|
536
|
+
notion.database("runtime-empty-dynamic", {
|
|
537
|
+
datasources: dynamicDatasources,
|
|
538
|
+
views: dynamicViews,
|
|
539
|
+
} as never),
|
|
540
|
+
).toThrow();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("allows linked view-only databases with omitted or empty datasources", () => {
|
|
544
|
+
const recorder = startMetadataRecording();
|
|
545
|
+
const linkedView = {
|
|
546
|
+
resourceId: "linked-view",
|
|
547
|
+
type: "table",
|
|
548
|
+
dataSourceResourceId: "external-source",
|
|
549
|
+
} as const;
|
|
550
|
+
const linkedWithEmptyDatasources = notion.database("linked-empty-database", {
|
|
551
|
+
datasources: {},
|
|
552
|
+
views: [linkedView],
|
|
553
|
+
});
|
|
554
|
+
const linkedWithoutDatasources = notion.database("linked-omitted-database", {
|
|
555
|
+
views: [
|
|
556
|
+
{
|
|
557
|
+
resourceId: "linked-omitted-view",
|
|
558
|
+
type: "table",
|
|
559
|
+
dataSourceResourceId: "another-external-source",
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
});
|
|
563
|
+
const invalidViewOnlyHandleAccess = () => {
|
|
564
|
+
// @ts-expect-error View-only databases have no statically known data sources.
|
|
565
|
+
void linkedWithEmptyDatasources.datasources.Missing;
|
|
566
|
+
// @ts-expect-error View-only databases have no statically known data sources.
|
|
567
|
+
void linkedWithoutDatasources.datasources.Missing;
|
|
568
|
+
};
|
|
569
|
+
void invalidViewOnlyHandleAccess;
|
|
570
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
571
|
+
|
|
572
|
+
expect(intents).toEqual([
|
|
573
|
+
{
|
|
574
|
+
type: "database",
|
|
575
|
+
resourceId: "linked-empty-database",
|
|
576
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
577
|
+
dataSources: [],
|
|
578
|
+
views: [linkedView],
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
type: "database",
|
|
582
|
+
resourceId: "linked-omitted-database",
|
|
583
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
584
|
+
dataSources: [],
|
|
585
|
+
views: [
|
|
586
|
+
{
|
|
587
|
+
resourceId: "linked-omitted-view",
|
|
588
|
+
type: "table",
|
|
589
|
+
dataSourceResourceId: "another-external-source",
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("preserves source metadata, false flags, and omits authoring fields from JSON", () => {
|
|
597
|
+
const recorder = startMetadataRecording();
|
|
598
|
+
notion.database("metadata-database", {
|
|
599
|
+
dataSourceResourceId: "metadata-source",
|
|
600
|
+
name: "Metadata",
|
|
601
|
+
schema: {
|
|
602
|
+
Name: { type: "title", resourceId: "metadata-name" },
|
|
603
|
+
},
|
|
604
|
+
views: [
|
|
605
|
+
{
|
|
606
|
+
resourceId: "metadata-view",
|
|
607
|
+
type: "table",
|
|
608
|
+
dataSourceResourceId: "metadata-source",
|
|
609
|
+
showPageIcon: false,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
hideDataSourceTitle: false,
|
|
613
|
+
hideDatabaseTitleIfEmpty: false,
|
|
614
|
+
});
|
|
615
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
616
|
+
expect(intents).toHaveLength(1);
|
|
617
|
+
const serialized = JSON.parse(JSON.stringify(intents[0])!);
|
|
618
|
+
|
|
619
|
+
expect(serialized).toEqual({
|
|
620
|
+
type: "database",
|
|
621
|
+
resourceId: "metadata-database",
|
|
622
|
+
name: "Metadata",
|
|
623
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
624
|
+
dataSources: [
|
|
625
|
+
{
|
|
626
|
+
resourceId: "metadata-source",
|
|
627
|
+
name: "Metadata",
|
|
628
|
+
properties: [{ type: "title", resourceId: "metadata-name", name: "Name" }],
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
views: [
|
|
632
|
+
{
|
|
633
|
+
resourceId: "metadata-view",
|
|
634
|
+
type: "table",
|
|
635
|
+
dataSourceResourceId: "metadata-source",
|
|
636
|
+
showPageIcon: false,
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
hideDataSourceTitle: false,
|
|
640
|
+
hideDatabaseTitleIfEmpty: false,
|
|
641
|
+
});
|
|
642
|
+
expect(serialized).not.toHaveProperty("schema");
|
|
643
|
+
expect(serialized).not.toHaveProperty("dataSourceResourceId");
|
|
644
|
+
expect(serialized).not.toHaveProperty("datasources");
|
|
645
|
+
expect(serialized).not.toHaveProperty("description");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("requires the status property configuration in authoring types", () => {
|
|
649
|
+
const invalidStatusDeclaration = () =>
|
|
650
|
+
notion.database("missing-status-options-database", {
|
|
651
|
+
dataSourceResourceId: "missing-status-options-source",
|
|
652
|
+
name: "Invalid",
|
|
653
|
+
schema: {
|
|
654
|
+
Name: { type: "title", resourceId: "missing-status-title" },
|
|
655
|
+
// @ts-expect-error Status properties require all three option groups.
|
|
656
|
+
Status: { type: "status", resourceId: "missing-status-id" },
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
void invalidStatusDeclaration;
|
|
660
|
+
});
|
|
661
|
+
});
|