@notionhq/custom-blocks 0.1.33 → 0.1.35

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 (36) hide show
  1. package/dist/bridge/SandboxBridge.d.ts +3 -1
  2. package/dist/bridge/SandboxBridge.d.ts.map +1 -1
  3. package/dist/bridge/SandboxBridge.js +55 -47
  4. package/dist/bridge/dataSources/query.d.ts +27 -0
  5. package/dist/bridge/dataSources/query.d.ts.map +1 -0
  6. package/dist/bridge/dataSources/query.js +512 -0
  7. package/dist/bridge/hostState.d.ts +4 -3
  8. package/dist/bridge/hostState.d.ts.map +1 -1
  9. package/dist/bridge/hostState.js +7 -3
  10. package/dist/bridge/sandboxClient.d.ts +4 -2
  11. package/dist/bridge/sandboxClient.d.ts.map +1 -1
  12. package/dist/bridge/sandboxClient.js +10 -4
  13. package/dist/protocol/dataSources/propertySchema.d.ts +7 -0
  14. package/dist/protocol/dataSources/propertySchema.d.ts.map +1 -1
  15. package/dist/protocol/dataSources/propertySchema.js +17 -0
  16. package/dist/protocol/messages/queryDataSource.d.ts +970 -0
  17. package/dist/protocol/messages/queryDataSource.d.ts.map +1 -1
  18. package/dist/protocol/messages/queryDataSource.js +108 -0
  19. package/dist/protocol/messages/queryDataSourceResult.d.ts +1 -1
  20. package/dist/protocol/messages/queryDataSourceResult.d.ts.map +1 -1
  21. package/dist/protocol/messages/sandboxToHost.d.ts +353 -0
  22. package/dist/protocol/messages/sandboxToHost.d.ts.map +1 -1
  23. package/dist/react/useDataSource.d.ts +1 -1
  24. package/dist/react/useDataSource.d.ts.map +1 -1
  25. package/dist/react/useDataSource.js +11 -6
  26. package/dist/types.d.ts +52 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/version.js +1 -1
  29. package/docs/data-sources.md +51 -3
  30. package/package.json +1 -1
  31. package/src/bridge/SandboxBridge.ts +66 -57
  32. package/src/bridge/dataSources/query.ts +694 -0
  33. package/src/bridge/hostState.ts +11 -3
  34. package/src/bridge/sandboxClient.ts +20 -4
  35. package/src/react/useDataSource.ts +17 -6
  36. package/src/types.ts +55 -0
@@ -0,0 +1,694 @@
1
+ import type { NotionDataSource } from "@notionhq/custom-blocks-protocol/dataSources/dataSource.js"
2
+ import { CUSTOM_BLOCK_DATA_SOURCE_SORTABLE_PROPERTY_TYPES } from "@notionhq/custom-blocks-protocol/dataSources/propertySchema.js"
3
+ import type { NotionDataSourceId } from "@notionhq/custom-blocks-protocol/ids.js"
4
+ import {
5
+ CUSTOM_BLOCK_DATA_SOURCE_QUERY_MAX_SORTS,
6
+ customBlockCheckboxFilterOperatorSchema,
7
+ customBlockContainsFilterOperatorSchema,
8
+ customBlockDateFilterOperatorSchema,
9
+ customBlockNumberFilterOperatorSchema,
10
+ customBlockOptionFilterOperatorSchema,
11
+ customBlockTextFilterOperatorSchema,
12
+ type ResolvedCustomBlockDataSourceFilter,
13
+ type ResolvedCustomBlockDataSourcePropertyFilter,
14
+ type ResolvedCustomBlockDataSourceSort,
15
+ } from "@notionhq/custom-blocks-protocol/messages/queryDataSource.js"
16
+ import * as v from "valibot"
17
+ import type { UseDataSourceOptions } from "../../types.js"
18
+ import { unreachable } from "../../utils.js"
19
+
20
+ const DEFAULT_DATA_SOURCE_QUERY_LIMIT = 20
21
+ const MAX_DATA_SOURCE_QUERY_LIMIT = 999
22
+ const MAX_DATA_SOURCE_QUERY_FILTER_CHILDREN = 25
23
+ const supportedDataSourceSortPropertyTypes = new Set<string>(
24
+ CUSTOM_BLOCK_DATA_SOURCE_SORTABLE_PROPERTY_TYPES,
25
+ )
26
+
27
+ export type ResolvedDataSourceQuery = {
28
+ dataSourceId: NotionDataSourceId
29
+ limit: number
30
+ filter?: ResolvedCustomBlockDataSourceFilter
31
+ sorts?: ResolvedCustomBlockDataSourceSort[]
32
+ identity: string
33
+ }
34
+
35
+ export type ResolveDataSourceQueryResult =
36
+ | { status: "error"; error: string }
37
+ | {
38
+ status: "ok"
39
+ dataSource: NotionDataSource
40
+ query: ResolvedDataSourceQuery
41
+ }
42
+
43
+ type DataSourceQueryOptions = {
44
+ limit?: unknown
45
+ filter?: unknown
46
+ sorts?: unknown
47
+ }
48
+
49
+ type ResolvePropertyFilterResult =
50
+ | { status: "ok"; filter: ResolvedCustomBlockDataSourcePropertyFilter }
51
+ | { status: "error"; error: string }
52
+
53
+ export function resolveDataSourceQuery(args: {
54
+ dataSources: NotionDataSource[]
55
+ key: string
56
+ options?: unknown
57
+ warn?: (message: string) => void
58
+ }): ResolveDataSourceQueryResult {
59
+ const { dataSources, key, options, warn } = args
60
+ const resolvedOptions = resolveDataSourceQueryOptions(options)
61
+ if (resolvedOptions.status === "error") {
62
+ return resolvedOptions
63
+ }
64
+ const queryOptions = resolvedOptions.options
65
+ const dataSource = dataSources.find(entry => entry.key === key)
66
+ if (dataSource === undefined) {
67
+ return {
68
+ status: "error",
69
+ error: `Unknown data source key "${key}". Known keys: [${dataSources.map(entry => entry.key).join(", ")}].`,
70
+ }
71
+ }
72
+ if (dataSource.collectionPointer === undefined) {
73
+ return {
74
+ status: "error",
75
+ error: `Data source "${key}" has not been mapped to a database yet.`,
76
+ }
77
+ }
78
+
79
+ const limit = resolveDataSourceQueryLimit(queryOptions?.limit, warn)
80
+ if (limit.status === "error") {
81
+ return limit
82
+ }
83
+ const filter = resolveFilter(queryOptions?.filter, dataSource)
84
+ if (filter.status === "error") {
85
+ return filter
86
+ }
87
+ const sorts = resolveSorts(queryOptions?.sorts, dataSource)
88
+ if (sorts.status === "error") {
89
+ return sorts
90
+ }
91
+
92
+ const identity = stableJsonStringify({
93
+ dataSourceId: dataSource.collectionPointer.id,
94
+ limit: limit.limit,
95
+ filter: filter.filter ?? null,
96
+ sorts: sorts.sorts ?? [],
97
+ })
98
+ return {
99
+ status: "ok",
100
+ dataSource,
101
+ query: {
102
+ dataSourceId: dataSource.collectionPointer.id,
103
+ limit: limit.limit,
104
+ filter: filter.filter,
105
+ sorts: sorts.sorts,
106
+ identity,
107
+ },
108
+ }
109
+ }
110
+
111
+ function resolveDataSourceQueryOptions(
112
+ options: unknown,
113
+ ):
114
+ | { status: "ok"; options?: DataSourceQueryOptions }
115
+ | { status: "error"; error: string } {
116
+ if (options === undefined) {
117
+ return { status: "ok" }
118
+ }
119
+ if (
120
+ typeof options !== "object" ||
121
+ options === null ||
122
+ Array.isArray(options)
123
+ ) {
124
+ return {
125
+ status: "error",
126
+ error: "Data source query options must be an object.",
127
+ }
128
+ }
129
+ const prototype = Object.getPrototypeOf(options)
130
+ if (prototype !== Object.prototype && prototype !== null) {
131
+ return {
132
+ status: "error",
133
+ error: "Data source query options must be an object.",
134
+ }
135
+ }
136
+ const optionsObject = options as Record<string, unknown>
137
+ if (
138
+ Object.keys(optionsObject).some(
139
+ key => !["limit", "filter", "sorts"].includes(key),
140
+ )
141
+ ) {
142
+ return {
143
+ status: "error",
144
+ error: "Data source query options contain unsupported fields.",
145
+ }
146
+ }
147
+ return {
148
+ status: "ok",
149
+ options: {
150
+ limit: Object.prototype.hasOwnProperty.call(optionsObject, "limit")
151
+ ? optionsObject.limit
152
+ : undefined,
153
+ filter: Object.prototype.hasOwnProperty.call(optionsObject, "filter")
154
+ ? optionsObject.filter
155
+ : undefined,
156
+ sorts: Object.prototype.hasOwnProperty.call(optionsObject, "sorts")
157
+ ? optionsObject.sorts
158
+ : undefined,
159
+ },
160
+ }
161
+ }
162
+
163
+ function resolveDataSourceQueryLimit(
164
+ limit: unknown,
165
+ warn: ((message: string) => void) | undefined = console.warn,
166
+ ): { status: "ok"; limit: number } | { status: "error"; error: string } {
167
+ if (limit === undefined) {
168
+ return { status: "ok", limit: DEFAULT_DATA_SOURCE_QUERY_LIMIT }
169
+ }
170
+ if (
171
+ typeof limit !== "number" ||
172
+ !Number.isFinite(limit) ||
173
+ !Number.isInteger(limit) ||
174
+ limit < 1
175
+ ) {
176
+ return {
177
+ status: "error",
178
+ error: `Data source query limit must be a positive integer between 1 and ${MAX_DATA_SOURCE_QUERY_LIMIT}.`,
179
+ }
180
+ }
181
+ if (limit > MAX_DATA_SOURCE_QUERY_LIMIT) {
182
+ warn?.(
183
+ `Data source query limit ${limit} exceeds the maximum of ${MAX_DATA_SOURCE_QUERY_LIMIT}; clamping to ${MAX_DATA_SOURCE_QUERY_LIMIT}.`,
184
+ )
185
+ return { status: "ok", limit: MAX_DATA_SOURCE_QUERY_LIMIT }
186
+ }
187
+ return { status: "ok", limit }
188
+ }
189
+
190
+ export function getDataSourceQueryOptionsIdentity(
191
+ options: UseDataSourceOptions | undefined,
192
+ ): string {
193
+ try {
194
+ return stableJsonStringify(options ?? {})
195
+ } catch {
196
+ return "invalid-data-source-query-options"
197
+ }
198
+ }
199
+
200
+ function stableJsonStringify(value: unknown): string {
201
+ return JSON.stringify(normalizeJsonValue(value))
202
+ }
203
+
204
+ function resolveFilter(
205
+ filter: unknown,
206
+ dataSource: NotionDataSource,
207
+ ):
208
+ | { status: "ok"; filter?: ResolvedCustomBlockDataSourceFilter }
209
+ | { status: "error"; error: string } {
210
+ if (filter === undefined) {
211
+ return { status: "ok" }
212
+ }
213
+ if (typeof filter !== "object" || filter === null || Array.isArray(filter)) {
214
+ return {
215
+ status: "error",
216
+ error: "Data source query filter must be an object.",
217
+ }
218
+ }
219
+ const filterObject = filter as Record<string, unknown>
220
+ if (Object.prototype.hasOwnProperty.call(filterObject, "and")) {
221
+ if (
222
+ Object.keys(filterObject).length !== 1 ||
223
+ !Array.isArray(filterObject.and)
224
+ ) {
225
+ return {
226
+ status: "error",
227
+ error: 'Data source query filter "and" must be an array.',
228
+ }
229
+ }
230
+ if (filterObject.and.length > MAX_DATA_SOURCE_QUERY_FILTER_CHILDREN) {
231
+ return {
232
+ status: "error",
233
+ error: `Data source query filter "and" may contain at most ${MAX_DATA_SOURCE_QUERY_FILTER_CHILDREN} children.`,
234
+ }
235
+ }
236
+ const and: ResolvedCustomBlockDataSourcePropertyFilter[] = []
237
+ for (const child of filterObject.and) {
238
+ const resolved = resolvePropertyFilter(child, dataSource)
239
+ if (resolved.status === "error") {
240
+ return resolved
241
+ }
242
+ and.push(resolved.filter)
243
+ }
244
+ return { status: "ok", filter: { and } }
245
+ }
246
+ return resolvePropertyFilter(filterObject, dataSource)
247
+ }
248
+
249
+ function resolveSorts(
250
+ sorts: unknown,
251
+ dataSource: NotionDataSource,
252
+ ):
253
+ | { status: "ok"; sorts?: ResolvedCustomBlockDataSourceSort[] }
254
+ | { status: "error"; error: string } {
255
+ if (sorts === undefined) {
256
+ return { status: "ok" }
257
+ }
258
+ if (!Array.isArray(sorts)) {
259
+ return {
260
+ status: "error",
261
+ error: "Data source query sorts must be an array.",
262
+ }
263
+ }
264
+ if (sorts.length === 0) {
265
+ return { status: "ok" }
266
+ }
267
+ if (sorts.length > CUSTOM_BLOCK_DATA_SOURCE_QUERY_MAX_SORTS) {
268
+ return {
269
+ status: "error",
270
+ error: `Data source query sorts may contain at most ${CUSTOM_BLOCK_DATA_SOURCE_QUERY_MAX_SORTS} entries.`,
271
+ }
272
+ }
273
+
274
+ const resolvedSorts: ResolvedCustomBlockDataSourceSort[] = []
275
+ const seenPropertyIds = new Set<string>()
276
+ for (const sort of sorts) {
277
+ const resolved = resolveSort(sort, dataSource)
278
+ if (resolved.status === "error") {
279
+ return resolved
280
+ }
281
+ if (seenPropertyIds.has(resolved.sort.propertyId)) {
282
+ return {
283
+ status: "error",
284
+ error: `Data source query sorts cannot contain duplicate property ID "${resolved.sort.propertyId}".`,
285
+ }
286
+ }
287
+ seenPropertyIds.add(resolved.sort.propertyId)
288
+ resolvedSorts.push(resolved.sort)
289
+ }
290
+ return { status: "ok", sorts: resolvedSorts }
291
+ }
292
+
293
+ function resolveSort(
294
+ sort: unknown,
295
+ dataSource: NotionDataSource,
296
+ ):
297
+ | { status: "ok"; sort: ResolvedCustomBlockDataSourceSort }
298
+ | { status: "error"; error: string } {
299
+ if (typeof sort !== "object" || sort === null || Array.isArray(sort)) {
300
+ return {
301
+ status: "error",
302
+ error: "Data source query sort must be an object.",
303
+ }
304
+ }
305
+ const sortObject = sort as Record<string, unknown>
306
+ if (
307
+ Object.keys(sortObject).some(
308
+ key => !["key", "propertyId", "direction"].includes(key),
309
+ )
310
+ ) {
311
+ return {
312
+ status: "error",
313
+ error: "Data source query sort contains unsupported fields.",
314
+ }
315
+ }
316
+ const property = resolvePropertyAddress(sortObject, dataSource)
317
+ if (property.status === "error") {
318
+ return property
319
+ }
320
+ if (!supportedDataSourceSortPropertyTypes.has(property.propertyType)) {
321
+ return {
322
+ status: "error",
323
+ error: `Data source query sorts do not yet support property type "${property.propertyType}".`,
324
+ }
325
+ }
326
+ const direction = sortObject.direction
327
+ if (
328
+ !Object.prototype.hasOwnProperty.call(sortObject, "direction") ||
329
+ (direction !== "ascending" && direction !== "descending")
330
+ ) {
331
+ return {
332
+ status: "error",
333
+ error:
334
+ 'Data source query sort direction must be "ascending" or "descending".',
335
+ }
336
+ }
337
+ return {
338
+ status: "ok",
339
+ sort: { propertyId: property.propertyId, direction },
340
+ }
341
+ }
342
+
343
+ function resolvePropertyFilter(
344
+ filter: unknown,
345
+ dataSource: NotionDataSource,
346
+ ): ResolvePropertyFilterResult {
347
+ if (typeof filter !== "object" || filter === null || Array.isArray(filter)) {
348
+ return {
349
+ status: "error",
350
+ error: "Data source query property filter must be an object.",
351
+ }
352
+ }
353
+ const filterObject = filter as Record<string, unknown>
354
+ const property = resolvePropertyAddress(filterObject, dataSource)
355
+ if (property.status === "error") {
356
+ return property
357
+ }
358
+ const branchKeys = Object.keys(filterObject).filter(
359
+ key => key !== "key" && key !== "propertyId",
360
+ )
361
+ if (branchKeys.length !== 1) {
362
+ return {
363
+ status: "error",
364
+ error:
365
+ "Data source query property filter must contain exactly one property branch.",
366
+ }
367
+ }
368
+ const branch = branchKeys[0]
369
+ const value = filterObject[branch]
370
+ if (
371
+ typeof value !== "object" ||
372
+ value === null ||
373
+ Array.isArray(value) ||
374
+ Object.keys(value).length !== 1
375
+ ) {
376
+ return invalidOperator(branch)
377
+ }
378
+ if (property.propertyType !== branch) {
379
+ return {
380
+ status: "error",
381
+ error: `Data source query filter branch "${branch}" does not match property type "${property.propertyType}".`,
382
+ }
383
+ }
384
+
385
+ return resolvePropertyFilterBranch({
386
+ propertyId: property.propertyId,
387
+ branch,
388
+ value: value as Record<string, unknown>,
389
+ })
390
+ }
391
+
392
+ function resolvePropertyFilterBranch(args: {
393
+ propertyId: string
394
+ branch: string
395
+ value: Record<string, unknown>
396
+ }): ResolvePropertyFilterResult {
397
+ switch (args.branch) {
398
+ case "title":
399
+ case "rich_text":
400
+ case "url":
401
+ case "email":
402
+ case "phone_number":
403
+ return resolveTextPropertyFilter({
404
+ propertyId: args.propertyId,
405
+ branch: args.branch,
406
+ value: args.value,
407
+ })
408
+ case "number":
409
+ return resolveNumberPropertyFilter(args.propertyId, args.value)
410
+ case "checkbox":
411
+ return resolveCheckboxPropertyFilter(args.propertyId, args.value)
412
+ case "select":
413
+ case "status":
414
+ return resolveOptionPropertyFilter({
415
+ propertyId: args.propertyId,
416
+ branch: args.branch,
417
+ value: args.value,
418
+ })
419
+ case "multi_select":
420
+ return resolveMultiSelectPropertyFilter(args.propertyId, args.value)
421
+ case "date":
422
+ return resolveDatePropertyFilter(args.propertyId, args.value)
423
+ default:
424
+ return {
425
+ status: "error",
426
+ error: `Data source query filter branch "${args.branch}" is not supported.`,
427
+ }
428
+ }
429
+ }
430
+
431
+ function resolveTextPropertyFilter(args: {
432
+ propertyId: string
433
+ branch: "title" | "rich_text" | "url" | "email" | "phone_number"
434
+ value: Record<string, unknown>
435
+ }): ResolvePropertyFilterResult {
436
+ const parsed = v.safeParse(customBlockTextFilterOperatorSchema, args.value)
437
+ if (!parsed.success) {
438
+ return invalidOperator(args.branch)
439
+ }
440
+ switch (args.branch) {
441
+ case "title":
442
+ return {
443
+ status: "ok",
444
+ filter: { propertyId: args.propertyId, title: parsed.output },
445
+ }
446
+ case "rich_text":
447
+ return {
448
+ status: "ok",
449
+ filter: { propertyId: args.propertyId, rich_text: parsed.output },
450
+ }
451
+ case "url":
452
+ return {
453
+ status: "ok",
454
+ filter: { propertyId: args.propertyId, url: parsed.output },
455
+ }
456
+ case "email":
457
+ return {
458
+ status: "ok",
459
+ filter: { propertyId: args.propertyId, email: parsed.output },
460
+ }
461
+ case "phone_number":
462
+ return {
463
+ status: "ok",
464
+ filter: { propertyId: args.propertyId, phone_number: parsed.output },
465
+ }
466
+ default:
467
+ return unreachable(args.branch)
468
+ }
469
+ }
470
+
471
+ function resolveNumberPropertyFilter(
472
+ propertyId: string,
473
+ value: Record<string, unknown>,
474
+ ): ResolvePropertyFilterResult {
475
+ const parsed = v.safeParse(customBlockNumberFilterOperatorSchema, value)
476
+ if (
477
+ !parsed.success ||
478
+ !Object.values(parsed.output).every(
479
+ entry => typeof entry !== "number" || Number.isFinite(entry),
480
+ )
481
+ ) {
482
+ return invalidOperator("number")
483
+ }
484
+ return {
485
+ status: "ok",
486
+ filter: { propertyId, number: parsed.output },
487
+ }
488
+ }
489
+
490
+ function resolveCheckboxPropertyFilter(
491
+ propertyId: string,
492
+ value: Record<string, unknown>,
493
+ ): ResolvePropertyFilterResult {
494
+ const parsed = v.safeParse(customBlockCheckboxFilterOperatorSchema, value)
495
+ return parsed.success
496
+ ? { status: "ok", filter: { propertyId, checkbox: parsed.output } }
497
+ : invalidOperator("checkbox")
498
+ }
499
+
500
+ function resolveOptionPropertyFilter(args: {
501
+ propertyId: string
502
+ branch: "select" | "status"
503
+ value: Record<string, unknown>
504
+ }): ResolvePropertyFilterResult {
505
+ const parsed = v.safeParse(customBlockOptionFilterOperatorSchema, args.value)
506
+ if (!parsed.success) {
507
+ return invalidOperator(args.branch)
508
+ }
509
+ switch (args.branch) {
510
+ case "select":
511
+ return {
512
+ status: "ok",
513
+ filter: { propertyId: args.propertyId, select: parsed.output },
514
+ }
515
+ case "status":
516
+ return {
517
+ status: "ok",
518
+ filter: { propertyId: args.propertyId, status: parsed.output },
519
+ }
520
+ default:
521
+ return unreachable(args.branch)
522
+ }
523
+ }
524
+
525
+ function resolveMultiSelectPropertyFilter(
526
+ propertyId: string,
527
+ value: Record<string, unknown>,
528
+ ): ResolvePropertyFilterResult {
529
+ const parsed = v.safeParse(customBlockContainsFilterOperatorSchema, value)
530
+ return parsed.success
531
+ ? { status: "ok", filter: { propertyId, multi_select: parsed.output } }
532
+ : invalidOperator("multi_select")
533
+ }
534
+
535
+ function resolveDatePropertyFilter(
536
+ propertyId: string,
537
+ value: Record<string, unknown>,
538
+ ): ResolvePropertyFilterResult {
539
+ const parsed = v.safeParse(customBlockDateFilterOperatorSchema, value)
540
+ if (
541
+ !parsed.success ||
542
+ !Object.values(parsed.output).every(
543
+ entry => entry === true || isValidIsoDate(entry),
544
+ )
545
+ ) {
546
+ return invalidOperator("date")
547
+ }
548
+ return {
549
+ status: "ok",
550
+ filter: { propertyId, date: parsed.output },
551
+ }
552
+ }
553
+
554
+ function resolvePropertyAddress(
555
+ value: Record<string, unknown>,
556
+ dataSource: NotionDataSource,
557
+ ):
558
+ | { status: "ok"; propertyId: string; propertyType: string }
559
+ | { status: "error"; error: string } {
560
+ const hasKey = Object.prototype.hasOwnProperty.call(value, "key")
561
+ const hasPropertyId = Object.prototype.hasOwnProperty.call(
562
+ value,
563
+ "propertyId",
564
+ )
565
+ if (hasKey === hasPropertyId) {
566
+ return {
567
+ status: "error",
568
+ error:
569
+ "Data source query filters and sorts must use exactly one of key or propertyId.",
570
+ }
571
+ }
572
+ let propertyId: string
573
+ if (hasKey) {
574
+ if (typeof value.key !== "string") {
575
+ return {
576
+ status: "error",
577
+ error: "Data source query property key must be a string.",
578
+ }
579
+ }
580
+ if (
581
+ !Object.prototype.hasOwnProperty.call(
582
+ dataSource.propertyIdsByKey,
583
+ value.key,
584
+ )
585
+ ) {
586
+ return {
587
+ status: "error",
588
+ error: `Unknown property key "${value.key}" for data source "${dataSource.key}".`,
589
+ }
590
+ }
591
+ const resolvedPropertyId = dataSource.propertyIdsByKey[value.key]
592
+ if (resolvedPropertyId === undefined) {
593
+ return {
594
+ status: "error",
595
+ error: `Property key "${value.key}" for data source "${dataSource.key}" is not bound.`,
596
+ }
597
+ }
598
+ propertyId = resolvedPropertyId
599
+ } else {
600
+ if (typeof value.propertyId !== "string") {
601
+ return {
602
+ status: "error",
603
+ error: "Data source query propertyId must be a string.",
604
+ }
605
+ }
606
+ propertyId = value.propertyId
607
+ }
608
+ if (
609
+ !Object.prototype.hasOwnProperty.call(
610
+ dataSource.propertySchemasById,
611
+ propertyId,
612
+ )
613
+ ) {
614
+ return {
615
+ status: "error",
616
+ error: `Unknown property ID "${propertyId}" for data source "${dataSource.key}".`,
617
+ }
618
+ }
619
+ const propertySchema = dataSource.propertySchemasById[propertyId]
620
+ return {
621
+ status: "ok",
622
+ propertyId,
623
+ propertyType: propertySchema.type,
624
+ }
625
+ }
626
+
627
+ function invalidOperator(branch: string): { status: "error"; error: string } {
628
+ return {
629
+ status: "error",
630
+ error: `Data source query filter branch "${branch}" has an invalid operator or value.`,
631
+ }
632
+ }
633
+
634
+ function normalizeJsonValue(value: unknown): unknown {
635
+ if (
636
+ value === null ||
637
+ typeof value === "string" ||
638
+ typeof value === "boolean"
639
+ ) {
640
+ return value
641
+ }
642
+ if (typeof value === "number") {
643
+ return normalizeJsonNumber(value)
644
+ }
645
+ if (Array.isArray(value)) {
646
+ return value.map(entry => normalizeJsonValue(entry))
647
+ }
648
+ if (typeof value === "object") {
649
+ return normalizeJsonObject(value)
650
+ }
651
+ throw new Error("Data source query values must be JSON-compatible.")
652
+ }
653
+
654
+ function normalizeJsonNumber(value: number): number {
655
+ if (!Number.isFinite(value)) {
656
+ throw new Error("Data source query values must be finite JSON numbers.")
657
+ }
658
+ return value
659
+ }
660
+
661
+ function normalizeJsonObject(value: object): Record<string, unknown> {
662
+ const prototype = Object.getPrototypeOf(value)
663
+ if (prototype !== Object.prototype && prototype !== null) {
664
+ throw new Error("Data source query values must be JSON-compatible.")
665
+ }
666
+ const objectValue = value as Record<string, unknown>
667
+ const normalized: Record<string, unknown> = {}
668
+ for (const key of Object.keys(objectValue).sort()) {
669
+ const child = objectValue[key]
670
+ if (child !== undefined) {
671
+ normalized[key] = normalizeJsonValue(child)
672
+ }
673
+ }
674
+ return normalized
675
+ }
676
+
677
+ function isValidIsoDate(value: unknown): boolean {
678
+ if (typeof value !== "string") {
679
+ return false
680
+ }
681
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
682
+ if (dateOnly !== null) {
683
+ const [, year, month, day] = dateOnly
684
+ const date = new Date(
685
+ Date.UTC(Number(year), Number(month) - 1, Number(day)),
686
+ )
687
+ return date.toISOString().slice(0, 10) === value
688
+ }
689
+ return (
690
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
691
+ value,
692
+ ) && Number.isFinite(Date.parse(value))
693
+ )
694
+ }