@crombolo/data 0.0.0-stage → 0.9.1
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 +31 -4
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +128 -0
- package/src/index.ts +60 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +431 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +191 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +190 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +73 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- package/README.md +0 -3
package/src/node-ref.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node named on a command line: either its uuid, or a path through the tree
|
|
3
|
+
* by name.
|
|
4
|
+
*
|
|
5
|
+
* Paths exist because nobody hand-types `9a1f3c2e-…`, and the person running a
|
|
6
|
+
* transfer knows where content sits, not what its key is. One resolver serves
|
|
7
|
+
* `content export --root` and `content import --under` so the two cannot drift
|
|
8
|
+
* in what they accept.
|
|
9
|
+
*
|
|
10
|
+
* Three rules keep the convenience from becoming ambiguity:
|
|
11
|
+
*
|
|
12
|
+
* - a uuid-shaped argument is always a uuid, never a path, so a path can never
|
|
13
|
+
* be mistaken for a key or the other way round;
|
|
14
|
+
* - a path matches case-insensitively by node name, from the content root, with
|
|
15
|
+
* a leading slash optional;
|
|
16
|
+
* - a path matching nothing, or more than one node, is an error that names the
|
|
17
|
+
* candidates. Guessing here would place content somewhere nobody chose.
|
|
18
|
+
*/
|
|
19
|
+
import { SystemNodes } from '@crombolo/core'
|
|
20
|
+
import type { Db } from './database.ts'
|
|
21
|
+
import { NodeRepository, type NodeRow } from './repositories/nodes.ts'
|
|
22
|
+
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
24
|
+
|
|
25
|
+
export type NodeRefResolution =
|
|
26
|
+
| { ok: true; node: NodeRow }
|
|
27
|
+
| {
|
|
28
|
+
ok: false
|
|
29
|
+
reason: 'not-found' | 'ambiguous'
|
|
30
|
+
/** The segment that failed, or the whole reference when it was a key. */
|
|
31
|
+
at: string
|
|
32
|
+
/** What was there instead, to print alongside the failure. */
|
|
33
|
+
candidates: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const looksLikeKey = (ref: string): boolean => UUID.test(ref.trim())
|
|
37
|
+
|
|
38
|
+
/** `/Campaigns/Autumn 2026` → `['Campaigns', 'Autumn 2026']` */
|
|
39
|
+
export function pathSegments(ref: string): string[] {
|
|
40
|
+
return ref
|
|
41
|
+
.split('/')
|
|
42
|
+
.map((segment) => segment.trim())
|
|
43
|
+
.filter((segment) => segment.length > 0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves a reference against the tree of `objectTypes`. Pass every object type
|
|
48
|
+
* a segment could name — a media path runs through media folders, and a document
|
|
49
|
+
* path through documents only.
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveNodeRef(
|
|
52
|
+
db: Db,
|
|
53
|
+
objectTypes: readonly string[],
|
|
54
|
+
ref: string,
|
|
55
|
+
): Promise<NodeRefResolution> {
|
|
56
|
+
const nodes = new NodeRepository(db)
|
|
57
|
+
const trimmed = ref.trim()
|
|
58
|
+
if (looksLikeKey(trimmed)) {
|
|
59
|
+
const node = await nodes.byKey(trimmed)
|
|
60
|
+
return node
|
|
61
|
+
? { ok: true, node }
|
|
62
|
+
: { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const segments = pathSegments(trimmed)
|
|
66
|
+
if (segments.length === 0) return { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
|
|
67
|
+
|
|
68
|
+
let parentId: number = SystemNodes.Root
|
|
69
|
+
let resolved: NodeRow | undefined
|
|
70
|
+
for (const segment of segments) {
|
|
71
|
+
const matches = await nodes.childrenNamed(parentId, objectTypes, segment)
|
|
72
|
+
if (matches.length === 0)
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: 'not-found',
|
|
76
|
+
at: segment,
|
|
77
|
+
candidates: await nodes.childNames(parentId, objectTypes),
|
|
78
|
+
}
|
|
79
|
+
if (matches.length > 1)
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: 'ambiguous',
|
|
83
|
+
at: segment,
|
|
84
|
+
// Two siblings of one name: the keys are the only way to tell them apart.
|
|
85
|
+
candidates: matches.map((m) => m.key),
|
|
86
|
+
}
|
|
87
|
+
resolved = matches[0] as NodeRow
|
|
88
|
+
parentId = resolved.id
|
|
89
|
+
}
|
|
90
|
+
return resolved
|
|
91
|
+
? { ok: true, node: resolved }
|
|
92
|
+
: { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The message a CLI or a finding prints for a failed resolution. */
|
|
96
|
+
export function describeRefFailure(ref: string, failure: NodeRefResolution): string {
|
|
97
|
+
if (failure.ok) return ''
|
|
98
|
+
if (failure.reason === 'ambiguous')
|
|
99
|
+
return `"${ref}": more than one node is named "${failure.at}" here — name one by key: ${failure.candidates.join(', ')}`
|
|
100
|
+
const there =
|
|
101
|
+
failure.candidates.length > 0
|
|
102
|
+
? ` — there is ${failure.candidates.map((c) => `"${c}"`).join(', ')}`
|
|
103
|
+
: ''
|
|
104
|
+
return looksLikeKey(ref)
|
|
105
|
+
? `"${ref}": no node with that key here`
|
|
106
|
+
: `"${ref}": nothing named "${failure.at}"${there}`
|
|
107
|
+
}
|
package/src/pending.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An element is pending on a node when the deployment that created it is newer
|
|
3
|
+
* than the node, or its file says `since` a version the node has not reached.
|
|
4
|
+
* The one place the rule lives. docs/09-schema-as-code.md, "since".
|
|
5
|
+
*/
|
|
6
|
+
import { compareStates, compareVersions, type NodeSchemaState } from './schema-state.ts'
|
|
7
|
+
|
|
8
|
+
export interface SinceMarkers {
|
|
9
|
+
/** The `(version, revision)` of the state that created the row, when known. */
|
|
10
|
+
since: NodeSchemaState | null
|
|
11
|
+
/** The file's explicit `since`, when written. */
|
|
12
|
+
sinceVersion: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The state the element goes live in, or null when it is live on this node. */
|
|
16
|
+
export function pendingUntil(markers: SinceMarkers, node: NodeSchemaState): NodeSchemaState | null {
|
|
17
|
+
if (markers.sinceVersion && compareVersions(markers.sinceVersion, node.version) > 0)
|
|
18
|
+
return { version: markers.sinceVersion, revision: '0' }
|
|
19
|
+
if (markers.since && compareStates(markers.since, node) > 0) return markers.since
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isPending(markers: SinceMarkers, node: NodeSchemaState): boolean {
|
|
24
|
+
return pendingUntil(markers, node) !== null
|
|
25
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundle definitions: what a bundle would contain, not a built bundle.
|
|
3
|
+
* `docs/17-bundles.md`.
|
|
4
|
+
*
|
|
5
|
+
* The selection lists travel as JSON text in single columns. A malformed list
|
|
6
|
+
* reads back as empty rather than throwing: a definition whose row was edited
|
|
7
|
+
* by hand should still list and still be deletable.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
12
|
+
|
|
13
|
+
export interface Bundle {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
contentNodeId: string | null
|
|
17
|
+
contentLoadChildNodes: boolean
|
|
18
|
+
mediaIds: string[]
|
|
19
|
+
mediaLoadChildNodes: boolean
|
|
20
|
+
elementIds: string[] | null
|
|
21
|
+
contentTypes: string[]
|
|
22
|
+
mediaTypes: string[]
|
|
23
|
+
dataTypes: string[]
|
|
24
|
+
components: string[]
|
|
25
|
+
stylesheets: string[]
|
|
26
|
+
scripts: string[]
|
|
27
|
+
languages: string[]
|
|
28
|
+
dictionaryItems: string[]
|
|
29
|
+
createDate: Date
|
|
30
|
+
updateDate: Date
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A definition to store; `id` is assigned when it is new, the dates always. */
|
|
34
|
+
export type BundleInput = Omit<Bundle, 'id' | 'createDate' | 'updateDate'>
|
|
35
|
+
|
|
36
|
+
interface Row {
|
|
37
|
+
id: string
|
|
38
|
+
name: string
|
|
39
|
+
content_node_id: string | null
|
|
40
|
+
content_load_child_nodes: unknown
|
|
41
|
+
media_load_child_nodes: unknown
|
|
42
|
+
media_ids: string
|
|
43
|
+
element_ids: string | null
|
|
44
|
+
content_types: string
|
|
45
|
+
media_types: string
|
|
46
|
+
data_types: string
|
|
47
|
+
components: string
|
|
48
|
+
stylesheets: string
|
|
49
|
+
scripts: string
|
|
50
|
+
languages: string
|
|
51
|
+
dictionary_items: string
|
|
52
|
+
create_date: unknown
|
|
53
|
+
update_date: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SELECT = `SELECT id, name, content_node_id, content_load_child_nodes,
|
|
57
|
+
media_load_child_nodes, media_ids, element_ids, content_types,
|
|
58
|
+
media_types, data_types, components, stylesheets,
|
|
59
|
+
scripts, languages, dictionary_items, create_date, update_date
|
|
60
|
+
FROM bundle`
|
|
61
|
+
|
|
62
|
+
const list = (value: string | null): string[] => {
|
|
63
|
+
if (!value) return []
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(value) as unknown
|
|
66
|
+
return Array.isArray(parsed)
|
|
67
|
+
? parsed.filter((item): item is string => typeof item === 'string')
|
|
68
|
+
: []
|
|
69
|
+
} catch {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hydrate = (row: Row): Bundle => ({
|
|
75
|
+
id: row.id,
|
|
76
|
+
name: row.name,
|
|
77
|
+
contentNodeId: row.content_node_id,
|
|
78
|
+
contentLoadChildNodes: fromDbBool(row.content_load_child_nodes),
|
|
79
|
+
mediaIds: list(row.media_ids),
|
|
80
|
+
mediaLoadChildNodes: fromDbBool(row.media_load_child_nodes),
|
|
81
|
+
elementIds: row.element_ids === null ? null : list(row.element_ids),
|
|
82
|
+
contentTypes: list(row.content_types),
|
|
83
|
+
mediaTypes: list(row.media_types),
|
|
84
|
+
dataTypes: list(row.data_types),
|
|
85
|
+
components: list(row.components),
|
|
86
|
+
stylesheets: list(row.stylesheets),
|
|
87
|
+
scripts: list(row.scripts),
|
|
88
|
+
languages: list(row.languages),
|
|
89
|
+
dictionaryItems: list(row.dictionary_items),
|
|
90
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
91
|
+
updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export class BundleRepository {
|
|
95
|
+
#db: Db
|
|
96
|
+
constructor(db: Db) {
|
|
97
|
+
this.#db = db
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** By name, which is the order the section lists them in. */
|
|
101
|
+
async list(
|
|
102
|
+
options: { skip?: number; take?: number } = {},
|
|
103
|
+
): Promise<{ total: number; items: Bundle[] }> {
|
|
104
|
+
const counted = await this.#db.query<{ total: number }>('SELECT COUNT(*) AS total FROM bundle')
|
|
105
|
+
const rows = await this.#db.query<Row>(`${SELECT} ORDER BY name LIMIT ? OFFSET ?`, [
|
|
106
|
+
options.take ?? 100,
|
|
107
|
+
options.skip ?? 0,
|
|
108
|
+
])
|
|
109
|
+
return { total: Number(counted[0]?.total ?? 0), items: rows.map(hydrate) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async byId(id: string): Promise<Bundle | undefined> {
|
|
113
|
+
const rows = await this.#db.query<Row>(`${SELECT} WHERE id = ?`, [id])
|
|
114
|
+
const row = rows[0]
|
|
115
|
+
return row ? hydrate(row) : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async byName(name: string): Promise<Bundle | undefined> {
|
|
119
|
+
const rows = await this.#db.query<Row>(`${SELECT} WHERE LOWER(name) = ?`, [
|
|
120
|
+
name.trim().toLowerCase(),
|
|
121
|
+
])
|
|
122
|
+
const row = rows[0]
|
|
123
|
+
return row ? hydrate(row) : undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async create(input: BundleInput, id: string = randomUUID()): Promise<Bundle> {
|
|
127
|
+
const now = new Date()
|
|
128
|
+
await this.#db.exec(
|
|
129
|
+
`INSERT INTO bundle
|
|
130
|
+
(id, name, content_node_id, content_load_child_nodes, media_load_child_nodes,
|
|
131
|
+
media_ids, element_ids, content_types, media_types, data_types, components,
|
|
132
|
+
stylesheets, scripts, languages, dictionary_items,
|
|
133
|
+
create_date, update_date)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
135
|
+
[
|
|
136
|
+
id,
|
|
137
|
+
input.name,
|
|
138
|
+
input.contentNodeId,
|
|
139
|
+
this.#db.dialect.boolValue(input.contentLoadChildNodes),
|
|
140
|
+
this.#db.dialect.boolValue(input.mediaLoadChildNodes),
|
|
141
|
+
JSON.stringify(input.mediaIds),
|
|
142
|
+
input.elementIds === null ? null : JSON.stringify(input.elementIds),
|
|
143
|
+
JSON.stringify(input.contentTypes),
|
|
144
|
+
JSON.stringify(input.mediaTypes),
|
|
145
|
+
JSON.stringify(input.dataTypes),
|
|
146
|
+
JSON.stringify(input.components),
|
|
147
|
+
JSON.stringify(input.stylesheets),
|
|
148
|
+
JSON.stringify(input.scripts),
|
|
149
|
+
JSON.stringify(input.languages),
|
|
150
|
+
JSON.stringify(input.dictionaryItems),
|
|
151
|
+
DbDate.toDb(now),
|
|
152
|
+
DbDate.toDb(now),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
return { ...input, id, createDate: now, updateDate: now }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async update(id: string, input: BundleInput): Promise<void> {
|
|
159
|
+
await this.#db.exec(
|
|
160
|
+
`UPDATE bundle
|
|
161
|
+
SET name = ?, content_node_id = ?, content_load_child_nodes = ?,
|
|
162
|
+
media_load_child_nodes = ?, media_ids = ?, element_ids = ?,
|
|
163
|
+
content_types = ?, media_types = ?, data_types = ?, components = ?,
|
|
164
|
+
stylesheets = ?, scripts = ?, languages = ?,
|
|
165
|
+
dictionary_items = ?, update_date = ?
|
|
166
|
+
WHERE id = ?`,
|
|
167
|
+
[
|
|
168
|
+
input.name,
|
|
169
|
+
input.contentNodeId,
|
|
170
|
+
this.#db.dialect.boolValue(input.contentLoadChildNodes),
|
|
171
|
+
this.#db.dialect.boolValue(input.mediaLoadChildNodes),
|
|
172
|
+
JSON.stringify(input.mediaIds),
|
|
173
|
+
input.elementIds === null ? null : JSON.stringify(input.elementIds),
|
|
174
|
+
JSON.stringify(input.contentTypes),
|
|
175
|
+
JSON.stringify(input.mediaTypes),
|
|
176
|
+
JSON.stringify(input.dataTypes),
|
|
177
|
+
JSON.stringify(input.components),
|
|
178
|
+
JSON.stringify(input.stylesheets),
|
|
179
|
+
JSON.stringify(input.scripts),
|
|
180
|
+
JSON.stringify(input.languages),
|
|
181
|
+
JSON.stringify(input.dictionaryItems),
|
|
182
|
+
DbDate.toDb(new Date()),
|
|
183
|
+
id,
|
|
184
|
+
],
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async delete(id: string): Promise<void> {
|
|
189
|
+
await this.#db.exec('DELETE FROM bundle WHERE id = ?', [id])
|
|
190
|
+
}
|
|
191
|
+
}
|