@crombolo/server 0.0.0-stage → 0.10.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 +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@crombolo/api-management": "0.10.0",
|
|
32
|
+
"@crombolo/assistant": "0.10.0",
|
|
33
|
+
"@crombolo/auth": "0.10.0",
|
|
34
|
+
"@crombolo/backoffice-host": "0.10.0",
|
|
35
|
+
"@crombolo/contracts": "0.10.0",
|
|
36
|
+
"@crombolo/core": "0.10.0",
|
|
37
|
+
"@crombolo/data": "0.10.0",
|
|
38
|
+
"@crombolo/render": "0.10.0",
|
|
39
|
+
"@crombolo/schema": "0.10.0",
|
|
40
|
+
"@crombolo/transfer": "0.10.0",
|
|
41
|
+
"@logtape/logtape": "^2.3.8"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What authorization needs from the database: where nodes sit in their trees,
|
|
3
|
+
* and each signed-in user's combined start nodes, group verbs and languages.
|
|
4
|
+
*/
|
|
5
|
+
import type { NodeChain, NodeLookup, Principal } from '@crombolo/api-management'
|
|
6
|
+
import { combineStartNodes, normaliseUuid, ObjectTypes } from '@crombolo/core'
|
|
7
|
+
import { type Db, fromDbBool, UserRepository } from '@crombolo/data'
|
|
8
|
+
|
|
9
|
+
const inList = (values: readonly unknown[]) => values.map(() => '?').join(', ')
|
|
10
|
+
|
|
11
|
+
async function chains(
|
|
12
|
+
db: Db,
|
|
13
|
+
rows: Array<{ id: number; unique_id: string; path: string; trashed: unknown }>,
|
|
14
|
+
): Promise<Map<string, NodeChain>> {
|
|
15
|
+
const ids = [
|
|
16
|
+
...new Set(
|
|
17
|
+
rows.flatMap((r) =>
|
|
18
|
+
String(r.path)
|
|
19
|
+
.split(',')
|
|
20
|
+
.map(Number)
|
|
21
|
+
.filter((id) => id > 0),
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
]
|
|
25
|
+
const keys = new Map<number, string>()
|
|
26
|
+
if (ids.length > 0)
|
|
27
|
+
for (const row of await db.query<{ id: number; unique_id: string }>(
|
|
28
|
+
`SELECT id, unique_id FROM node WHERE id IN (${inList(ids)})`,
|
|
29
|
+
ids,
|
|
30
|
+
))
|
|
31
|
+
keys.set(Number(row.id), normaliseUuid(String(row.unique_id)))
|
|
32
|
+
return new Map(
|
|
33
|
+
rows.map((row) => [
|
|
34
|
+
normaliseUuid(String(row.unique_id)),
|
|
35
|
+
{
|
|
36
|
+
chain: String(row.path)
|
|
37
|
+
.split(',')
|
|
38
|
+
.map(Number)
|
|
39
|
+
.filter((id) => id > 0)
|
|
40
|
+
.flatMap((id) => {
|
|
41
|
+
const key = keys.get(id)
|
|
42
|
+
return key ? [key] : []
|
|
43
|
+
}),
|
|
44
|
+
trashed: fromDbBool(row.trashed),
|
|
45
|
+
},
|
|
46
|
+
]),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createNodeLookup(db: Db): NodeLookup {
|
|
51
|
+
const byKeys = async (objectType: string, keys: readonly string[]) => {
|
|
52
|
+
const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
|
|
53
|
+
if (wanted.length === 0) return new Map<string, NodeChain>()
|
|
54
|
+
return chains(
|
|
55
|
+
db,
|
|
56
|
+
await db.query(
|
|
57
|
+
`SELECT id, unique_id, path, trashed FROM node
|
|
58
|
+
WHERE node_object_type = ? AND unique_id IN (${inList(wanted)})`,
|
|
59
|
+
[objectType, ...wanted],
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
documents: (keys) => byKeys(ObjectTypes.Document, keys),
|
|
65
|
+
media: (keys) => byKeys(ObjectTypes.Media, keys),
|
|
66
|
+
async documentDescendants(key) {
|
|
67
|
+
const node = (
|
|
68
|
+
await db.query<{ path: string }>('SELECT path FROM node WHERE unique_id = ?', [
|
|
69
|
+
normaliseUuid(key),
|
|
70
|
+
])
|
|
71
|
+
)[0]
|
|
72
|
+
if (!node) return []
|
|
73
|
+
return [
|
|
74
|
+
...(
|
|
75
|
+
await chains(
|
|
76
|
+
db,
|
|
77
|
+
await db.query(
|
|
78
|
+
'SELECT id, unique_id, path, trashed FROM node WHERE node_object_type = ? AND path LIKE ?',
|
|
79
|
+
[ObjectTypes.Document, `${node.path},%`],
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
).values(),
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
async versionDocument(versionId) {
|
|
86
|
+
const id = Number(versionId)
|
|
87
|
+
if (!Number.isInteger(id)) return undefined
|
|
88
|
+
const row = (
|
|
89
|
+
await db.query<{ unique_id: string }>(
|
|
90
|
+
'SELECT n.unique_id FROM content_version cv JOIN node n ON n.id = cv.node_id WHERE cv.id = ?',
|
|
91
|
+
[id],
|
|
92
|
+
)
|
|
93
|
+
)[0]
|
|
94
|
+
return row ? normaliseUuid(String(row.unique_id)) : undefined
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A user's languages, combined start nodes per tree, and group verbs. */
|
|
100
|
+
export async function loadAccess(
|
|
101
|
+
db: Db,
|
|
102
|
+
userId: number,
|
|
103
|
+
): Promise<Pick<Principal, 'languages' | 'startNodes' | 'groups'>> {
|
|
104
|
+
const data = await new UserRepository(db).access(userId)
|
|
105
|
+
return {
|
|
106
|
+
languages: data.languages,
|
|
107
|
+
groups: data.groups,
|
|
108
|
+
startNodes: {
|
|
109
|
+
document: combineStartNodes(data.groupStartNodes.document, data.userStartNodes.document),
|
|
110
|
+
media: combineStartNodes(data.groupStartNodes.media, data.userStartNodes.media),
|
|
111
|
+
element: combineStartNodes(data.groupStartNodes.element, data.userStartNodes.element),
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changeset persistence for the assistant, over the tables migration 017 adds.
|
|
3
|
+
*
|
|
4
|
+
* A proposal has to outlive the conversation that produced it: one raised over
|
|
5
|
+
* MCP by an agent in a terminal is reviewed in the backoffice by a person in a
|
|
6
|
+
* browser, in another process. `body` and `params` are stored as JSON because
|
|
7
|
+
* they are a prepared Management API request and nothing here inspects them —
|
|
8
|
+
* only the approval path, which dispatches them.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
ChangeOrigin,
|
|
12
|
+
ChangeStatus,
|
|
13
|
+
Changeset,
|
|
14
|
+
ChangesetStore,
|
|
15
|
+
ProposedChange,
|
|
16
|
+
} from '@crombolo/assistant'
|
|
17
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
18
|
+
import { type Db, DbDate } from '@crombolo/data'
|
|
19
|
+
|
|
20
|
+
interface ChangesetRow {
|
|
21
|
+
key: string
|
|
22
|
+
user_key: string
|
|
23
|
+
title: string
|
|
24
|
+
origin: string
|
|
25
|
+
create_date: unknown
|
|
26
|
+
update_date: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ChangeRow {
|
|
30
|
+
key: string
|
|
31
|
+
kind: string
|
|
32
|
+
summary: string
|
|
33
|
+
status: string
|
|
34
|
+
params: string
|
|
35
|
+
body: string
|
|
36
|
+
baseline: string | null
|
|
37
|
+
problems: string
|
|
38
|
+
error: string | null
|
|
39
|
+
create_date: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const iso = (value: unknown): string => (DbDate.fromDb(value) ?? new Date(0)).toISOString()
|
|
43
|
+
|
|
44
|
+
const parse = <T>(value: string, fallback: T): T => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value) as T
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hydrateChange = (row: ChangeRow): ProposedChange => ({
|
|
53
|
+
key: normaliseUuid(String(row.key)),
|
|
54
|
+
kind: String(row.kind) as ProposedChange['kind'],
|
|
55
|
+
summary: String(row.summary),
|
|
56
|
+
params: parse<Record<string, string>>(String(row.params), {}),
|
|
57
|
+
body: parse<unknown>(String(row.body), null),
|
|
58
|
+
baseline: row.baseline === null ? undefined : String(row.baseline),
|
|
59
|
+
problems: parse<ProposedChange['problems']>(String(row.problems), []),
|
|
60
|
+
status: String(row.status) as ChangeStatus,
|
|
61
|
+
error: row.error === null ? undefined : String(row.error),
|
|
62
|
+
createDate: iso(row.create_date),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export function createChangesetStore(db: Db): ChangesetStore {
|
|
66
|
+
const changesOf = async (changesetKey: string): Promise<ProposedChange[]> => {
|
|
67
|
+
const rows = await db.query<ChangeRow>(
|
|
68
|
+
`SELECT key, kind, summary, status, params, body, baseline, problems, error, create_date
|
|
69
|
+
FROM assistant_change WHERE changeset_key = ? ORDER BY sort_order, id`,
|
|
70
|
+
[changesetKey],
|
|
71
|
+
)
|
|
72
|
+
return rows.map(hydrateChange)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hydrate = async (row: ChangesetRow): Promise<Changeset> => ({
|
|
76
|
+
key: normaliseUuid(String(row.key)),
|
|
77
|
+
userKey: normaliseUuid(String(row.user_key)),
|
|
78
|
+
title: String(row.title),
|
|
79
|
+
origin: String(row.origin) as ChangeOrigin,
|
|
80
|
+
changes: await changesOf(normaliseUuid(String(row.key))),
|
|
81
|
+
createDate: iso(row.create_date),
|
|
82
|
+
updateDate: iso(row.update_date),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const touch = (key: string) =>
|
|
86
|
+
db.exec('UPDATE assistant_changeset SET update_date = ? WHERE key = ?', [
|
|
87
|
+
DbDate.toDb(new Date()),
|
|
88
|
+
key,
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async create(input) {
|
|
93
|
+
const key = normaliseUuid(crypto.randomUUID())
|
|
94
|
+
const now = new Date()
|
|
95
|
+
await db.exec(
|
|
96
|
+
`INSERT INTO assistant_changeset (key, user_key, title, origin, create_date, update_date)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
98
|
+
[
|
|
99
|
+
key,
|
|
100
|
+
normaliseUuid(input.userKey),
|
|
101
|
+
input.title,
|
|
102
|
+
input.origin,
|
|
103
|
+
DbDate.toDb(now),
|
|
104
|
+
DbDate.toDb(now),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
return {
|
|
108
|
+
key,
|
|
109
|
+
userKey: normaliseUuid(input.userKey),
|
|
110
|
+
title: input.title,
|
|
111
|
+
origin: input.origin,
|
|
112
|
+
changes: [],
|
|
113
|
+
createDate: now.toISOString(),
|
|
114
|
+
updateDate: now.toISOString(),
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async load(key) {
|
|
119
|
+
const rows = await db.query<ChangesetRow>(
|
|
120
|
+
`SELECT key, user_key, title, origin, create_date, update_date
|
|
121
|
+
FROM assistant_changeset WHERE key = ?`,
|
|
122
|
+
[normaliseUuid(key)],
|
|
123
|
+
)
|
|
124
|
+
return rows[0] ? await hydrate(rows[0]) : undefined
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async listFor(userKey, limit = 20) {
|
|
128
|
+
const rows = await db.query<ChangesetRow>(
|
|
129
|
+
`SELECT key, user_key, title, origin, create_date, update_date
|
|
130
|
+
FROM assistant_changeset WHERE user_key = ?
|
|
131
|
+
ORDER BY update_date DESC, id DESC LIMIT ?`,
|
|
132
|
+
[normaliseUuid(userKey), limit],
|
|
133
|
+
)
|
|
134
|
+
const out: Changeset[] = []
|
|
135
|
+
for (const row of rows) out.push(await hydrate(row))
|
|
136
|
+
return out
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async changeFor(userKey, changeKey) {
|
|
140
|
+
const rows = await db.query<ChangeRow>(
|
|
141
|
+
`SELECT c.key, c.kind, c.summary, c.status, c.params, c.body, c.baseline, c.problems,
|
|
142
|
+
c.error, c.create_date
|
|
143
|
+
FROM assistant_change c
|
|
144
|
+
JOIN assistant_changeset s ON s.key = c.changeset_key
|
|
145
|
+
WHERE c.key = ? AND s.user_key = ?`,
|
|
146
|
+
[normaliseUuid(changeKey), normaliseUuid(userKey)],
|
|
147
|
+
)
|
|
148
|
+
return rows[0] ? hydrateChange(rows[0]) : undefined
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async openFor(userKey, origin) {
|
|
152
|
+
const rows = await db.query<ChangesetRow>(
|
|
153
|
+
`SELECT s.key, s.user_key, s.title, s.origin, s.create_date, s.update_date
|
|
154
|
+
FROM assistant_changeset s
|
|
155
|
+
WHERE s.user_key = ? AND s.origin = ?
|
|
156
|
+
AND EXISTS (SELECT 1 FROM assistant_change c
|
|
157
|
+
WHERE c.changeset_key = s.key AND c.status = 'proposed')
|
|
158
|
+
ORDER BY s.update_date DESC, s.id DESC LIMIT 1`,
|
|
159
|
+
[normaliseUuid(userKey), origin],
|
|
160
|
+
)
|
|
161
|
+
return rows[0] ? await hydrate(rows[0]) : undefined
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async append(changesetKey, change) {
|
|
165
|
+
const key = normaliseUuid(changesetKey)
|
|
166
|
+
const counted = await db.query<{ next: number | null }>(
|
|
167
|
+
'SELECT MAX(sort_order) AS next FROM assistant_change WHERE changeset_key = ?',
|
|
168
|
+
[key],
|
|
169
|
+
)
|
|
170
|
+
const sortOrder = (counted[0]?.next ?? -1) + 1
|
|
171
|
+
await db.exec(
|
|
172
|
+
`INSERT INTO assistant_change
|
|
173
|
+
(key, changeset_key, sort_order, kind, summary, status, params, body, baseline, problems, error, create_date)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
175
|
+
[
|
|
176
|
+
normaliseUuid(change.key),
|
|
177
|
+
key,
|
|
178
|
+
sortOrder,
|
|
179
|
+
change.kind,
|
|
180
|
+
change.summary,
|
|
181
|
+
change.status,
|
|
182
|
+
JSON.stringify(change.params),
|
|
183
|
+
JSON.stringify(change.body ?? null),
|
|
184
|
+
change.baseline ?? null,
|
|
185
|
+
JSON.stringify(change.problems),
|
|
186
|
+
change.error ?? null,
|
|
187
|
+
DbDate.toDb(new Date(change.createDate)),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
await touch(key)
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async replaceBody(changeKey, body, problems) {
|
|
194
|
+
const key = normaliseUuid(changeKey)
|
|
195
|
+
await db.exec('UPDATE assistant_change SET body = ?, problems = ? WHERE key = ?', [
|
|
196
|
+
JSON.stringify(body ?? null),
|
|
197
|
+
JSON.stringify(problems),
|
|
198
|
+
key,
|
|
199
|
+
])
|
|
200
|
+
const rows = await db.query<{ changeset_key: string }>(
|
|
201
|
+
'SELECT changeset_key FROM assistant_change WHERE key = ?',
|
|
202
|
+
[key],
|
|
203
|
+
)
|
|
204
|
+
const owner = rows[0]?.changeset_key
|
|
205
|
+
if (owner) await touch(String(owner))
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async settle(changeKey, outcome) {
|
|
209
|
+
const key = normaliseUuid(changeKey)
|
|
210
|
+
await db.exec('UPDATE assistant_change SET status = ?, error = ? WHERE key = ?', [
|
|
211
|
+
outcome.status,
|
|
212
|
+
outcome.error ?? null,
|
|
213
|
+
key,
|
|
214
|
+
])
|
|
215
|
+
const rows = await db.query<{ changeset_key: string }>(
|
|
216
|
+
'SELECT changeset_key FROM assistant_change WHERE key = ?',
|
|
217
|
+
[key],
|
|
218
|
+
)
|
|
219
|
+
const owner = rows[0]?.changeset_key
|
|
220
|
+
if (owner) await touch(String(owner))
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async remove(key) {
|
|
224
|
+
const owner = normaliseUuid(key)
|
|
225
|
+
await db.exec('DELETE FROM assistant_change WHERE changeset_key = ?', [owner])
|
|
226
|
+
await db.exec('DELETE FROM assistant_changeset WHERE key = ?', [owner])
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document blueprints ("Document Blueprints" in Settings, "Create Document
|
|
3
|
+
* Blueprint" on a page): stored as documents of their own object type in their
|
|
4
|
+
* own folder tree, never published.
|
|
5
|
+
*/
|
|
6
|
+
import type { BlueprintPort, Principal } from '@crombolo/api-management'
|
|
7
|
+
import { ObjectTypes, type TreeItem } from '@crombolo/core'
|
|
8
|
+
import {
|
|
9
|
+
type Db,
|
|
10
|
+
DocumentRepository,
|
|
11
|
+
FolderRepository,
|
|
12
|
+
type NodeSchemaState,
|
|
13
|
+
type ValueIntake,
|
|
14
|
+
WriteRejectedError,
|
|
15
|
+
} from '@crombolo/data'
|
|
16
|
+
import { createFolderPort, createSettingsTree } from './content.ts'
|
|
17
|
+
|
|
18
|
+
export function createBlueprintPort(
|
|
19
|
+
db: Db,
|
|
20
|
+
options: { nodeState?: NodeSchemaState; valueIntake?: ValueIntake } = {},
|
|
21
|
+
): BlueprintPort {
|
|
22
|
+
const repo = new DocumentRepository(db, {
|
|
23
|
+
nodeState: options.nodeState,
|
|
24
|
+
kind: 'blueprint',
|
|
25
|
+
valueIntake: options.valueIntake,
|
|
26
|
+
})
|
|
27
|
+
const documents = new DocumentRepository(db, { nodeState: options.nodeState })
|
|
28
|
+
const container = ObjectTypes.DocumentBlueprintContainer
|
|
29
|
+
const folders = new FolderRepository(db, container, ObjectTypes.DocumentBlueprint)
|
|
30
|
+
|
|
31
|
+
const decorate = async (item: TreeItem) => {
|
|
32
|
+
const blueprint = await repo.byKey(item.key)
|
|
33
|
+
item.icon = blueprint?.contentTypeIcon ?? 'icon-blueprint'
|
|
34
|
+
item.contentTypeKey = blueprint?.contentTypeKey ?? null
|
|
35
|
+
}
|
|
36
|
+
const tree = createSettingsTree(repo.nodes, container, ObjectTypes.DocumentBlueprint, decorate)
|
|
37
|
+
|
|
38
|
+
const userIdOf = async (principal: Principal): Promise<number | undefined> => {
|
|
39
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
40
|
+
principal.id,
|
|
41
|
+
])
|
|
42
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
43
|
+
}
|
|
44
|
+
const failure = (error: unknown) => {
|
|
45
|
+
if (error instanceof WriteRejectedError)
|
|
46
|
+
return { ok: false as const, reason: error.message, status: 409 }
|
|
47
|
+
return {
|
|
48
|
+
ok: false as const,
|
|
49
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
50
|
+
status: 400,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
byKey: (key) => repo.byKey(key),
|
|
56
|
+
|
|
57
|
+
async create(input, principal) {
|
|
58
|
+
try {
|
|
59
|
+
await repo.create({ ...input, componentKey: null, userId: await userIdOf(principal) })
|
|
60
|
+
return { ok: true as const }
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return failure(error)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async update(key, input, principal) {
|
|
67
|
+
try {
|
|
68
|
+
const updated = await repo.update({
|
|
69
|
+
...input,
|
|
70
|
+
key,
|
|
71
|
+
componentKey: null,
|
|
72
|
+
userId: await userIdOf(principal),
|
|
73
|
+
})
|
|
74
|
+
if (!updated)
|
|
75
|
+
return { ok: false as const, reason: 'The blueprint could not be found.', status: 404 }
|
|
76
|
+
return { ok: true as const }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return failure(error)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async fromDocument(documentKey, init, principal) {
|
|
83
|
+
const document = await documents.byKey(documentKey)
|
|
84
|
+
if (!document)
|
|
85
|
+
return { ok: false as const, reason: 'The document could not be found.', status: 404 }
|
|
86
|
+
try {
|
|
87
|
+
const created = await repo.create({
|
|
88
|
+
key: init.key,
|
|
89
|
+
contentTypeKey: document.contentTypeKey,
|
|
90
|
+
componentKey: null,
|
|
91
|
+
parentKey: init.parentKey,
|
|
92
|
+
values: document.values,
|
|
93
|
+
variants: document.variants.map((v) => ({
|
|
94
|
+
culture: v.culture,
|
|
95
|
+
segment: v.segment,
|
|
96
|
+
name: v.culture === null ? init.name : v.name,
|
|
97
|
+
})),
|
|
98
|
+
userId: await userIdOf(principal),
|
|
99
|
+
})
|
|
100
|
+
return { ok: true as const, key: created.key }
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return failure(error)
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
remove: (key) => repo.delete(key),
|
|
107
|
+
|
|
108
|
+
async move(key, targetKey) {
|
|
109
|
+
const result = await repo.move(key, targetKey)
|
|
110
|
+
if (result === 'moved') return { ok: true as const }
|
|
111
|
+
return result === 'notFound'
|
|
112
|
+
? { ok: false as const, reason: 'The blueprint or folder could not be found.', status: 404 }
|
|
113
|
+
: { ok: false as const, reason: 'A blueprint cannot move there.', status: 400 }
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
versions: (key) => repo.versions(key),
|
|
117
|
+
async systemUserKey() {
|
|
118
|
+
const rows = await db.query<{ key: string }>(
|
|
119
|
+
'SELECT key FROM user_account ORDER BY id LIMIT 1',
|
|
120
|
+
)
|
|
121
|
+
return rows[0] ? String(rows[0].key).toLowerCase() : null
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async forContentType(contentTypeKey, paging) {
|
|
125
|
+
const rows = await db.query(
|
|
126
|
+
`SELECT n.unique_id, n.text FROM node n JOIN content c ON c.node_id = n.id
|
|
127
|
+
JOIN node t ON t.id = c.content_type_id
|
|
128
|
+
WHERE n.node_object_type = ? AND t.unique_id = ? ORDER BY n.text, n.id`,
|
|
129
|
+
[ObjectTypes.DocumentBlueprint, contentTypeKey.toLowerCase()],
|
|
130
|
+
)
|
|
131
|
+
const all = rows.map((row) => ({
|
|
132
|
+
key: String(row.unique_id).toLowerCase(),
|
|
133
|
+
name: String(row.text ?? ''),
|
|
134
|
+
}))
|
|
135
|
+
return { total: all.length, items: all.slice(paging.skip, paging.skip + paging.take) }
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
treeRoot: tree.treeRoot,
|
|
139
|
+
treeChildren: tree.treeChildren,
|
|
140
|
+
treeAncestors: tree.ancestors,
|
|
141
|
+
treeSiblings: tree.siblings,
|
|
142
|
+
|
|
143
|
+
async items(keys) {
|
|
144
|
+
const found = []
|
|
145
|
+
for (const key of keys) {
|
|
146
|
+
const blueprint = await repo.byKey(key)
|
|
147
|
+
if (!blueprint) continue
|
|
148
|
+
found.push({
|
|
149
|
+
key: blueprint.key,
|
|
150
|
+
name:
|
|
151
|
+
blueprint.variants.find((v) => v.culture === null)?.name ??
|
|
152
|
+
blueprint.variants[0]?.name ??
|
|
153
|
+
'',
|
|
154
|
+
contentTypeKey: blueprint.contentTypeKey,
|
|
155
|
+
icon: blueprint.contentTypeIcon,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
return found
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
folders: createFolderPort(folders, tree, async () => {}),
|
|
162
|
+
}
|
|
163
|
+
}
|