@pathmx/core 0.5.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 (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. package/view/syntax.ts +372 -0
@@ -0,0 +1,776 @@
1
+ import { canonicalPath } from "../canonical.ts"
2
+ import { dependencyKey, type Dependency } from "../dependencies.ts"
3
+ import type { Engine } from "../host/engine.ts"
4
+ import {
5
+ formatSchemaIssues,
6
+ type StandardSchemaV1,
7
+ } from "../schema/standard.ts"
8
+ import { QueryFieldCatalog, queryRecordValue, queryScalar } from "./fields.ts"
9
+ import { QueryIndex, type IndexedBlock } from "./candidate-index.ts"
10
+ import {
11
+ QueryDependencyIndex,
12
+ type QueryInvalidation,
13
+ } from "./dependency-index.ts"
14
+ import type {
15
+ BlockQueryRequest,
16
+ QueryContribution,
17
+ QueryDefinition,
18
+ QueryDefinitionInput,
19
+ QueryDefinitionOutput,
20
+ QueryRequest,
21
+ QueryScalar,
22
+ QuerySort,
23
+ SourceQueryRequest,
24
+ ViewQuery,
25
+ } from "./model.ts"
26
+ import type { Source } from "../source/index.ts"
27
+ import type { BlockSummary, SourceSummary, View } from "../view/model.ts"
28
+ import type { ApplicationSchema } from "../schema/model.ts"
29
+ import {
30
+ projectBlockSummary,
31
+ projectSourceSummary,
32
+ } from "../view/projection.ts"
33
+
34
+ const MAX_QUERY_LIMIT = 1_000
35
+ const MAX_RETAINED_QUERY_PLANS = 2_000
36
+ const MAX_VIEW_CACHES_PER_QUERY = 256
37
+ const QUERY_DEPENDENCY_PLUGIN = "query"
38
+
39
+ export type PreparedQueryPlan = Readonly<{
40
+ query: string
41
+ from: string
42
+ input?: unknown
43
+ where: Readonly<Record<string, QueryScalar>>
44
+ sort: readonly Required<QuerySort>[]
45
+ limit: number
46
+ type?: string
47
+ has: readonly string[]
48
+ recursive: boolean
49
+ key: string
50
+ }>
51
+
52
+ type NormalizedRequest = PreparedQueryPlan
53
+
54
+ function queryValueError(): never {
55
+ throw new Error("Query inputs and results must contain only JSON values.")
56
+ }
57
+
58
+ function scalarJsonValue(value: unknown) {
59
+ if (
60
+ value === null ||
61
+ typeof value === "string" ||
62
+ typeof value === "boolean"
63
+ ) {
64
+ return value
65
+ }
66
+ if (typeof value === "number" && Number.isFinite(value)) return value
67
+ return queryValueError()
68
+ }
69
+
70
+ function queryObject(value: object) {
71
+ const prototype = Object.getPrototypeOf(value)
72
+ if (prototype !== Object.prototype && prototype !== null) queryValueError()
73
+ return value as Record<string, unknown>
74
+ }
75
+
76
+ function stableValue(value: unknown, stack = new WeakSet<object>()): unknown {
77
+ if (value == null || typeof value !== "object") return scalarJsonValue(value)
78
+ if (stack.has(value)) return queryValueError()
79
+ stack.add(value)
80
+ const normalized = Array.isArray(value)
81
+ ? value.map((item) => stableValue(item, stack))
82
+ : Object.fromEntries(
83
+ Object.entries(queryObject(value))
84
+ .sort(([left], [right]) => left.localeCompare(right))
85
+ .map(([key, item]) => [key, stableValue(item, stack)]),
86
+ )
87
+ stack.delete(value)
88
+ return normalized
89
+ }
90
+
91
+ function stableStringify(value: unknown) {
92
+ return JSON.stringify(stableValue(value))
93
+ }
94
+
95
+ function immutableValue(
96
+ value: unknown,
97
+ stack = new WeakSet<object>(),
98
+ ): unknown {
99
+ if (value == null || typeof value !== "object") return scalarJsonValue(value)
100
+ if (stack.has(value)) return queryValueError()
101
+ stack.add(value)
102
+ if (Array.isArray(value)) {
103
+ const result = value.map((item) => immutableValue(item, stack))
104
+ stack.delete(value)
105
+ return Object.freeze(result)
106
+ }
107
+ const result = Object.fromEntries(
108
+ Object.entries(queryObject(value)).map(([key, item]) => [
109
+ key,
110
+ immutableValue(item, stack),
111
+ ]),
112
+ )
113
+ stack.delete(value)
114
+ return Object.freeze(result)
115
+ }
116
+
117
+ async function validateSchema<Schema extends StandardSchemaV1>(
118
+ schema: Schema,
119
+ value: unknown,
120
+ subject: string,
121
+ ): Promise<StandardSchemaV1.InferOutput<Schema>> {
122
+ const result = await schema["~standard"].validate(value)
123
+ if (result.issues) {
124
+ throw new Error(`${subject}: ${formatSchemaIssues(result.issues)}`)
125
+ }
126
+ return result.value
127
+ }
128
+
129
+ function normalizedScope(value: string | undefined) {
130
+ if (value !== undefined && typeof value !== "string") {
131
+ throw new Error("Query from must be a string.")
132
+ }
133
+ const scope = value?.trim() || "/"
134
+ if (scope.startsWith("~/")) {
135
+ const suffix = canonicalPath(`/${scope.slice(2)}`)
136
+ return `~${suffix}`
137
+ }
138
+ return canonicalPath(scope)
139
+ }
140
+
141
+ function normalizedLimit(value: number | undefined) {
142
+ if (value == null) return MAX_QUERY_LIMIT
143
+ if (!Number.isSafeInteger(value) || value < 0) {
144
+ throw new Error("Query limit must be a non-negative safe integer.")
145
+ }
146
+ return Math.min(value, MAX_QUERY_LIMIT)
147
+ }
148
+
149
+ function normalizedWhere(value: QueryRequest["where"]) {
150
+ if (
151
+ value !== undefined &&
152
+ (value === null || typeof value !== "object" || Array.isArray(value))
153
+ ) {
154
+ throw new Error("Query where must be an object.")
155
+ }
156
+ const entries = Object.entries(value ?? {}).sort(([left], [right]) =>
157
+ left.localeCompare(right),
158
+ )
159
+ for (const [field, expected] of entries) {
160
+ if (!field.trim()) throw new Error("Query filter fields cannot be empty.")
161
+ if (!queryScalar(expected)) {
162
+ throw new Error(`Query filter ${field} must compare a scalar value.`)
163
+ }
164
+ }
165
+ return Object.freeze(Object.fromEntries(entries))
166
+ }
167
+
168
+ function normalizedSort(value: QueryRequest["sort"]) {
169
+ if (value !== undefined && !Array.isArray(value)) {
170
+ throw new Error("Query sort must be a list.")
171
+ }
172
+ return Object.freeze(
173
+ (value ?? []).map((sort) => {
174
+ if (sort == null || typeof sort !== "object") {
175
+ throw new Error("Each query sort must be an object.")
176
+ }
177
+ const by = sort.by?.trim()
178
+ if (!by) throw new Error("Query sort fields cannot be empty.")
179
+ const direction = sort.direction ?? "asc"
180
+ if (direction !== "asc" && direction !== "desc") {
181
+ throw new Error(`Unsupported query sort direction: ${direction}`)
182
+ }
183
+ return Object.freeze({ by, direction })
184
+ }),
185
+ )
186
+ }
187
+
188
+ function normalizedHas(value: SourceQueryRequest["has"]) {
189
+ const props = value == null ? [] : typeof value === "string" ? [value] : value
190
+ if (!Array.isArray(props) || props.some((prop) => typeof prop !== "string")) {
191
+ throw new Error("Query has must be a string or list of strings.")
192
+ }
193
+ return Object.freeze(
194
+ [...new Set(props.map((prop) => prop.trim()))]
195
+ .filter(Boolean)
196
+ .sort((left, right) => left.localeCompare(right)),
197
+ )
198
+ }
199
+
200
+ function matchesWhere(
201
+ record: unknown,
202
+ where: Readonly<Record<string, QueryScalar>>,
203
+ ) {
204
+ return Object.entries(where).every(([field, expected]) =>
205
+ Object.is(queryRecordValue(record, field), expected),
206
+ )
207
+ }
208
+
209
+ function compareScalar(left: unknown, right: unknown) {
210
+ if (left === right) return 0
211
+ if (left === undefined || left === null) return 1
212
+ if (right === undefined || right === null) return -1
213
+ if (typeof left === "number" && typeof right === "number") {
214
+ return left < right ? -1 : 1
215
+ }
216
+ if (typeof left === "boolean" && typeof right === "boolean") {
217
+ return left === false ? -1 : 1
218
+ }
219
+ const a = String(left)
220
+ const b = String(right)
221
+ return a < b ? -1 : a > b ? 1 : 0
222
+ }
223
+
224
+ function stableRecordKey(record: unknown) {
225
+ if (record != null && typeof record === "object") {
226
+ const value = record as Record<string, unknown>
227
+ if (typeof value.id === "string") {
228
+ const source = value.source
229
+ if (
230
+ source != null &&
231
+ typeof source === "object" &&
232
+ typeof (source as Record<string, unknown>).id === "string"
233
+ ) {
234
+ return `${(source as Record<string, unknown>).id}#${value.id}`
235
+ }
236
+ return value.id
237
+ }
238
+ }
239
+ return stableStringify(record)
240
+ }
241
+
242
+ function orderResults(
243
+ results: readonly unknown[],
244
+ sorts: readonly Required<QuerySort>[],
245
+ ) {
246
+ return [...results].sort((left, right) => {
247
+ for (const sort of sorts) {
248
+ const compared = compareScalar(
249
+ queryRecordValue(left, sort.by),
250
+ queryRecordValue(right, sort.by),
251
+ )
252
+ if (compared) return sort.direction === "desc" ? -compared : compared
253
+ }
254
+ return compareScalar(stableRecordKey(left), stableRecordKey(right))
255
+ })
256
+ }
257
+
258
+ function within(path: string, root: string, recursive: boolean) {
259
+ if (root !== "/" && path !== root && !path.startsWith(`${root}/`)) {
260
+ return false
261
+ }
262
+ if (recursive || path === root) return true
263
+ const relative = root === "/" ? path.slice(1) : path.slice(root.length + 1)
264
+ return !relative.includes("/")
265
+ }
266
+
267
+ function queryFieldCatalog(
268
+ schema: ApplicationSchema,
269
+ plan: NormalizedRequest,
270
+ namedOutput?: Readonly<Record<string, unknown>>,
271
+ ) {
272
+ const catalog = new QueryFieldCatalog()
273
+ if (plan.query === "sources" || plan.query === "blocks") {
274
+ catalog
275
+ .add("id")
276
+ .add("type")
277
+ .add("href")
278
+ .add("meta.title")
279
+ .add("meta.description")
280
+ .add("meta.image")
281
+ const types = plan.type
282
+ ? [plan.type]
283
+ : plan.query === "sources"
284
+ ? schema.sourceTypes().map(({ type }) => type)
285
+ : schema.blockTypes().map(({ type }) => type)
286
+ const schemas = types.flatMap((type) =>
287
+ (plan.query === "sources"
288
+ ? schema.sourceSchemas(type)
289
+ : schema.blockSchemas(type)
290
+ ).map(({ outputJsonSchema }) => outputJsonSchema),
291
+ )
292
+ for (const jsonSchema of schemas) catalog.addObjectSchema(jsonSchema)
293
+ for (const prop of schema.props()) {
294
+ catalog.addObjectSchema(prop.outputJsonSchema, prop.name)
295
+ }
296
+ if (plan.query === "blocks") {
297
+ catalog.add("source.id").add("source.type").add("source.href")
298
+ catalog.add("source.meta.title")
299
+ for (const type of schema.sourceTypes()) {
300
+ for (const sourceSchema of schema.sourceSchemas(type.type)) {
301
+ catalog.addObjectSchema(sourceSchema.outputJsonSchema, "source")
302
+ }
303
+ }
304
+ for (const prop of schema.props()) {
305
+ catalog.addObjectSchema(prop.outputJsonSchema, `source.${prop.name}`)
306
+ }
307
+ }
308
+ } else if (namedOutput) {
309
+ catalog.addArrayOutput(namedOutput)
310
+ }
311
+ for (const field of Object.keys(plan.where)) catalog.require(field, "filter")
312
+ for (const sort of plan.sort) catalog.require(sort.by, "sort")
313
+ return catalog
314
+ }
315
+
316
+ /** @internal Normalize and validate one query for execution or authored storage. */
317
+ export async function prepareQueryPlan(
318
+ schema: ApplicationSchema,
319
+ query: string,
320
+ request: QueryRequest<unknown> = {},
321
+ ) {
322
+ const builtIn = query === "sources" || query === "blocks"
323
+ const named = builtIn ? undefined : schema.query(query)
324
+ if (!builtIn && !named) throw new Error(`Unknown query: ${query}`)
325
+
326
+ let input = request.input
327
+ if (named?.input) {
328
+ input = immutableValue(
329
+ await validateSchema(
330
+ named.input,
331
+ request.input,
332
+ `Invalid ${query} query input`,
333
+ ),
334
+ )
335
+ } else if (request.input !== undefined) {
336
+ throw new Error(`Query ${query} does not accept input.`)
337
+ }
338
+
339
+ const sourceRequest = request as SourceQueryRequest
340
+ const allowed = new Set([
341
+ "from",
342
+ "input",
343
+ "where",
344
+ "sort",
345
+ "limit",
346
+ ...(builtIn ? ["type", "has", "recursive"] : []),
347
+ ])
348
+ for (const key of Object.keys(request)) {
349
+ if (!allowed.has(key))
350
+ throw new Error(`Unknown query request field: ${key}`)
351
+ }
352
+ if (
353
+ builtIn &&
354
+ sourceRequest.type !== undefined &&
355
+ typeof sourceRequest.type !== "string"
356
+ ) {
357
+ throw new Error("Query type must be a string.")
358
+ }
359
+ if (
360
+ builtIn &&
361
+ sourceRequest.recursive !== undefined &&
362
+ typeof sourceRequest.recursive !== "boolean"
363
+ ) {
364
+ throw new Error("Query recursive must be a boolean.")
365
+ }
366
+ const normalized = {
367
+ query,
368
+ from: normalizedScope(request.from),
369
+ ...(named?.input ? { input } : {}),
370
+ where: normalizedWhere(request.where),
371
+ sort: normalizedSort(request.sort),
372
+ limit: normalizedLimit(request.limit),
373
+ ...(builtIn && sourceRequest.type?.trim()
374
+ ? { type: sourceRequest.type.trim() }
375
+ : {}),
376
+ has: builtIn ? normalizedHas(sourceRequest.has) : Object.freeze([]),
377
+ recursive: builtIn ? sourceRequest.recursive !== false : true,
378
+ }
379
+ for (const prop of normalized.has) {
380
+ if (!schema.prop(prop)) {
381
+ throw new Error(`Unknown registered data prop in query has: ${prop}`)
382
+ }
383
+ }
384
+ const plan = Object.freeze({
385
+ ...normalized,
386
+ key: stableStringify(normalized),
387
+ }) satisfies PreparedQueryPlan
388
+ queryFieldCatalog(schema, plan, named?.outputJsonSchema)
389
+ return plan
390
+ }
391
+
392
+ /** @internal One coarse dependency represents the complete normalized result. */
393
+ export function queryDependency(
394
+ plan: Pick<PreparedQueryPlan, "key">,
395
+ ): Dependency {
396
+ return { type: "plugin", plugin: QUERY_DEPENDENCY_PLUGIN, key: plan.key }
397
+ }
398
+
399
+ /** @internal Discover scalar fields from the same catalog used for operators. */
400
+ export function queryPlanFields(
401
+ schema: ApplicationSchema,
402
+ plan: PreparedQueryPlan,
403
+ ) {
404
+ const named =
405
+ plan.query === "sources" || plan.query === "blocks"
406
+ ? undefined
407
+ : schema.query(plan.query)
408
+ return queryFieldCatalog(schema, plan, named?.outputJsonSchema).list()
409
+ }
410
+
411
+ export class Query {
412
+ private index = new QueryIndex()
413
+ private plans = new Map<string, NormalizedRequest>()
414
+ private caches = new Map<string, Map<string, Promise<readonly unknown[]>>>()
415
+ private dependencies = new QueryDependencyIndex(MAX_VIEW_CACHES_PER_QUERY)
416
+ private executingPlans = new Set<string>()
417
+ private registeredProps?: ReadonlySet<string>
418
+
419
+ constructor(private engine: Engine) {}
420
+
421
+ indexSource(source: Source) {
422
+ this.registeredProps ??= new Set(
423
+ this.engine.schema.props().map(({ name }) => name),
424
+ )
425
+ this.index.replace(source, this.registeredProps)
426
+ }
427
+
428
+ unindexSource(sourcePath: string) {
429
+ this.index.delete(sourcePath)
430
+ }
431
+
432
+ viewQuery(view: View): ViewQuery {
433
+ return Object.freeze({
434
+ sources: (request?: SourceQueryRequest) =>
435
+ this.execute(view, "sources", request) as Promise<
436
+ readonly SourceSummary[]
437
+ >,
438
+ blocks: (request?: BlockQueryRequest) =>
439
+ this.execute(view, "blocks", request) as Promise<
440
+ readonly BlockSummary[]
441
+ >,
442
+ run: <Definition extends QueryDefinition>(
443
+ definition: Definition,
444
+ request?: QueryRequest<QueryDefinitionInput<Definition>>,
445
+ ) =>
446
+ this.execute(
447
+ view,
448
+ definition as QueryContribution,
449
+ request as QueryRequest<unknown>,
450
+ ) as Promise<QueryDefinitionOutput<Definition>>,
451
+ })
452
+ }
453
+
454
+ run(
455
+ view: View,
456
+ query: string | QueryContribution,
457
+ request?: QueryRequest<unknown>,
458
+ ) {
459
+ return this.execute(view, query, request)
460
+ }
461
+
462
+ async prepare(
463
+ query: string | QueryContribution,
464
+ request: QueryRequest<unknown> = {},
465
+ ) {
466
+ const registered =
467
+ query === "sources" || query === "blocks"
468
+ ? undefined
469
+ : this.engine.registeredQuery(query)
470
+ const id = typeof query === "string" ? query : registered?.id
471
+ if (!id || (id !== "sources" && id !== "blocks" && !registered)) {
472
+ throw new Error(
473
+ `Unknown query: ${typeof query === "string" ? query : "unregistered definition"}`,
474
+ )
475
+ }
476
+ const plan = await prepareQueryPlan(this.engine.schema, id, request)
477
+ this.plans.delete(plan.key)
478
+ this.plans.set(plan.key, plan)
479
+ this.prunePlans(plan.key)
480
+ return plan
481
+ }
482
+
483
+ async fields(
484
+ query: string | QueryContribution,
485
+ request: QueryRequest<unknown> = {},
486
+ ) {
487
+ const plan = await this.prepare(query, request)
488
+ return queryPlanFields(this.engine.schema, plan)
489
+ }
490
+
491
+ dependency(plan: Pick<NormalizedRequest, "key">): Dependency {
492
+ return queryDependency(plan)
493
+ }
494
+
495
+ transition(before?: Source, after?: Source) {
496
+ const changed: QueryInvalidation[] = []
497
+ for (const plan of this.plans.values()) {
498
+ if (plan.query !== "sources" && plan.query !== "blocks") continue
499
+ const actors = this.transitionActors(plan, before, after)
500
+ if (actors === false) continue
501
+ changed.push({
502
+ key: plan.key,
503
+ ...(actors ? { actors: Object.freeze([...actors].sort()) } : {}),
504
+ })
505
+ }
506
+ return this.cascade(changed)
507
+ }
508
+
509
+ invalidateDependencies(dependencies: Iterable<Dependency>) {
510
+ const changed: QueryInvalidation[] = []
511
+ for (const dependency of dependencies) {
512
+ changed.push(...this.dependencies.dependentsOf(dependencyKey(dependency)))
513
+ }
514
+ return this.cascade(changed)
515
+ }
516
+
517
+ private async execute(
518
+ view: View,
519
+ query: string | QueryContribution,
520
+ request: QueryRequest<unknown> = {},
521
+ parentDependencies?: Set<string>,
522
+ ): Promise<readonly unknown[]> {
523
+ const plan = await this.prepare(query, request)
524
+ parentDependencies?.add(`query:${plan.key}`)
525
+ const scope =
526
+ view.principal.type === "actor"
527
+ ? `actor:${view.principal.actor.id}`
528
+ : "anonymous"
529
+ let byView = this.caches.get(plan.key)
530
+ if (!byView) {
531
+ byView = new Map()
532
+ this.caches.set(plan.key, byView)
533
+ }
534
+ const cached = byView.get(scope)
535
+ if (cached) {
536
+ byView.delete(scope)
537
+ byView.set(scope, cached)
538
+ return cached
539
+ }
540
+ this.executingPlans.add(plan.key)
541
+ const pending = this.resolve(view, plan, scope)
542
+ .then((results) =>
543
+ plan.query === "sources" || plan.query === "blocks"
544
+ ? results
545
+ : (immutableValue(results) as readonly unknown[]),
546
+ )
547
+ .catch((error) => {
548
+ byView!.delete(scope)
549
+ throw error
550
+ })
551
+ .finally(() => this.executingPlans.delete(plan.key))
552
+ byView.set(scope, pending)
553
+ while (byView.size > MAX_VIEW_CACHES_PER_QUERY) {
554
+ const oldest = byView.keys().next().value as string | undefined
555
+ if (oldest === undefined) break
556
+ byView.delete(oldest)
557
+ this.dependencies.deleteScope(plan.key, oldest)
558
+ }
559
+ return pending
560
+ }
561
+
562
+ private async resolve(view: View, plan: NormalizedRequest, scope: string) {
563
+ const from = this.resolvedScope(view, plan.from)
564
+ if (!from) return Object.freeze([])
565
+ if (plan.query === "sources") {
566
+ const results = []
567
+ for (const source of this.index.sourceCandidates(plan)) {
568
+ if (!within(source.id, from, plan.recursive)) continue
569
+ if (this.engine.readAccess(view.principal, source) === "denied")
570
+ continue
571
+ const projected = projectSourceSummary(source, this.engine.schema)
572
+ if (matchesWhere(projected, plan.where)) {
573
+ results.push(projected)
574
+ }
575
+ }
576
+ return Object.freeze(
577
+ orderResults(results, plan.sort).slice(0, plan.limit),
578
+ )
579
+ }
580
+ if (plan.query === "blocks") {
581
+ const results = []
582
+ for (const indexed of this.index.blockCandidates(plan)) {
583
+ if (!within(indexed.source.id, from, plan.recursive)) continue
584
+ if (
585
+ this.engine.readAccess(view.principal, indexed.source) === "denied"
586
+ ) {
587
+ continue
588
+ }
589
+ const projected = projectBlockSummary(
590
+ indexed.source,
591
+ indexed.block,
592
+ this.engine.schema,
593
+ )
594
+ if (matchesWhere(projected, plan.where)) {
595
+ results.push(projected)
596
+ }
597
+ }
598
+ return Object.freeze(
599
+ orderResults(results, plan.sort).slice(0, plan.limit),
600
+ )
601
+ }
602
+
603
+ const registered = this.engine.registeredQuery(plan.query)
604
+ if (!registered) throw new Error(`Unknown query: ${plan.query}`)
605
+ const dependencies = new Set<string>()
606
+ const query = Object.freeze({
607
+ sources: (request?: SourceQueryRequest) =>
608
+ this.execute(view, "sources", request, dependencies),
609
+ blocks: (request?: BlockQueryRequest) =>
610
+ this.execute(view, "blocks", request, dependencies),
611
+ run: (definition: QueryContribution, request?: QueryRequest<unknown>) =>
612
+ this.execute(view, definition, request, dependencies),
613
+ }) as ViewQuery
614
+ const resolved = await registered.definition.resolve(plan.input as never, {
615
+ view,
616
+ from,
617
+ query,
618
+ depend: (dependency: Dependency) =>
619
+ dependencies.add(dependencyKey(dependency)),
620
+ ...(this.engine.appContext(registered.plugin).database
621
+ ? { database: this.engine.appContext(registered.plugin).database }
622
+ : {}),
623
+ })
624
+ const validated = (await validateSchema(
625
+ registered.definition.output,
626
+ resolved,
627
+ `Invalid ${registered.id} query output`,
628
+ )) as readonly unknown[]
629
+ this.recordDependencies(plan.key, scope, dependencies)
630
+ return Object.freeze(
631
+ orderResults(
632
+ validated.filter((record) => matchesWhere(record, plan.where)),
633
+ plan.sort,
634
+ ).slice(0, plan.limit),
635
+ )
636
+ }
637
+
638
+ private resolvedScope(view: View, scope: string) {
639
+ if (!scope.startsWith("~/")) return scope
640
+ if (view.principal.type !== "actor") return
641
+ return canonicalPath(
642
+ this.engine.environment.path.join(
643
+ view.principal.actor.home,
644
+ scope.slice(2),
645
+ ),
646
+ )
647
+ }
648
+
649
+ private transitionActors(
650
+ plan: NormalizedRequest,
651
+ before?: Source,
652
+ after?: Source,
653
+ ): false | undefined | Set<string> {
654
+ if (!plan.from.startsWith("~/")) {
655
+ return this.transitionMatches(plan, before, after, plan.from)
656
+ ? undefined
657
+ : false
658
+ }
659
+ const actors = new Set<string>()
660
+ for (const source of [before, after]) {
661
+ if (!source) continue
662
+ const actorId = this.engine.homeActorId(source.path)
663
+ const actor = actorId ? this.engine.actor(actorId) : undefined
664
+ if (!actor) continue
665
+ const root = canonicalPath(
666
+ this.engine.environment.path.join(actor.home, plan.from.slice(2)),
667
+ )
668
+ if (this.sourceMatches(plan, source, root)) actors.add(actor.id)
669
+ }
670
+ return actors.size ? actors : false
671
+ }
672
+
673
+ private transitionMatches(
674
+ plan: NormalizedRequest,
675
+ before: Source | undefined,
676
+ after: Source | undefined,
677
+ root: string,
678
+ ) {
679
+ return [before, after].some(
680
+ (source) => source && this.sourceMatches(plan, source, root),
681
+ )
682
+ }
683
+
684
+ private sourceMatches(plan: NormalizedRequest, source: Source, root: string) {
685
+ if (!within(source.id, root, plan.recursive)) return false
686
+ if (plan.query === "sources") {
687
+ if (plan.type && source.type !== plan.type) return false
688
+ if (plan.has.some((prop) => !Object.hasOwn(source.data, prop)))
689
+ return false
690
+ const projected = projectSourceSummary(source, this.engine.schema)
691
+ return matchesWhere(projected, plan.where)
692
+ }
693
+ return source.blocks.some((block) => {
694
+ if (plan.type && block.type !== plan.type) return false
695
+ if (plan.has.some((prop) => !Object.hasOwn(block.data, prop)))
696
+ return false
697
+ return matchesWhere(
698
+ projectBlockSummary(source, block, this.engine.schema),
699
+ plan.where,
700
+ )
701
+ })
702
+ }
703
+
704
+ private recordDependencies(
705
+ plan: string,
706
+ scope: string,
707
+ dependencies: Set<string>,
708
+ ) {
709
+ this.dependencies.replace(plan, scope, dependencies)
710
+ }
711
+
712
+ private invalidateCaches(key: string, actors?: readonly string[]) {
713
+ if (!actors) {
714
+ this.caches.delete(key)
715
+ return
716
+ }
717
+ const byView = this.caches.get(key)
718
+ if (!byView) return
719
+ for (const actor of actors) byView.delete(`actor:${actor}`)
720
+ if (byView.size === 0) this.caches.delete(key)
721
+ }
722
+
723
+ private prunePlans(current: string) {
724
+ if (this.plans.size <= MAX_RETAINED_QUERY_PLANS) return
725
+ for (const key of this.plans.keys()) {
726
+ if (this.plans.size <= MAX_RETAINED_QUERY_PLANS) break
727
+ if (
728
+ key === current ||
729
+ this.executingPlans.has(key) ||
730
+ this.dependencies.hasDependents(`query:${key}`) ||
731
+ this.engine.dependentsOf(queryDependency({ key })).size > 0
732
+ ) {
733
+ continue
734
+ }
735
+ this.removePlan(key)
736
+ }
737
+ }
738
+
739
+ private removePlan(key: string) {
740
+ this.plans.delete(key)
741
+ this.caches.delete(key)
742
+ this.dependencies.clear(key)
743
+ }
744
+
745
+ private cascade(initial: readonly QueryInvalidation[]) {
746
+ const pending = [...initial]
747
+ const invalidated = new Map<string, Set<string> | undefined>()
748
+ while (pending.length) {
749
+ const next = pending.shift()!
750
+ const existing = invalidated.get(next.key)
751
+ if (invalidated.has(next.key) && existing === undefined) continue
752
+ let affected = next.actors
753
+ if (!affected) invalidated.set(next.key, undefined)
754
+ else {
755
+ affected = affected.filter((actor) => !existing?.has(actor))
756
+ if (!affected.length) continue
757
+ const actors = existing ?? new Set<string>()
758
+ for (const actor of affected) actors.add(actor)
759
+ invalidated.set(next.key, actors)
760
+ }
761
+ this.invalidateCaches(next.key, affected)
762
+ pending.push(
763
+ ...this.dependencies.dependentsOf(`query:${next.key}`, affected),
764
+ )
765
+ this.dependencies.clear(next.key, affected)
766
+ }
767
+ return Object.freeze(
768
+ [...invalidated].map(([key, actors]) =>
769
+ Object.freeze({
770
+ key,
771
+ ...(actors ? { actors: Object.freeze([...actors].sort()) } : {}),
772
+ }),
773
+ ),
774
+ )
775
+ }
776
+ }