@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
@@ -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
+ }