@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.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- package/view/syntax.ts +372 -0
package/query/kernel.ts
ADDED
|
@@ -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
|
+
}
|