@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
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,33 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1",
4
+ "description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
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/data"
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/core": "0.9.1"
32
+ }
33
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Findings, persisted. A run upserts by `(code, subject, property, culture)`
3
+ * within its own `(source, scope)` and resolves whatever it no longer reports,
4
+ * so the dashboard shows items disappearing as they are fixed.
5
+ *
6
+ * `source` is required rather than defaulted: the sweep resolves what a run
7
+ * omits, so a caller that forgot to say which findings it owns would silently
8
+ * resolve somebody else's. `scope` narrows it again — one bundle's findings do
9
+ * not resolve another's.
10
+ */
11
+ import type { Db } from './database.ts'
12
+ import { DbDate } from './dialect.ts'
13
+
14
+ export type FindingKind = 'auto' | 'person' | 'blocking'
15
+
16
+ /** Who a run of findings belongs to. Scopes the resolve sweep. */
17
+ export type FindingSource = 'upgrade' | 'transfer'
18
+
19
+ export interface Finding {
20
+ kind: FindingKind
21
+ /** Stable machine name: 'mandatory-missing', 'value-conversion', … */
22
+ code: string
23
+ subjectType:
24
+ | 'document'
25
+ | 'document-type'
26
+ | 'property'
27
+ | 'migration'
28
+ | 'file'
29
+ | 'data-type'
30
+ | 'media'
31
+ | 'element'
32
+ | 'bundle'
33
+ | 'dictionary-item'
34
+ subjectKey: string
35
+ subjectName: string
36
+ propertyAlias: string | null
37
+ culture: string | null
38
+ message: string
39
+ /** A backoffice URL, when the subject has one. */
40
+ link: string | null
41
+ }
42
+
43
+ export interface ReportedFinding extends Finding {
44
+ id: number
45
+ runId: string
46
+ source: FindingSource
47
+ /** What within the source these findings are about: a bundle id, or null for an upgrade. */
48
+ scope: string | null
49
+ status: 'open' | 'resolved'
50
+ firstSeen: Date
51
+ lastSeen: Date
52
+ resolvedAt: Date | null
53
+ }
54
+
55
+ export interface WriteReportOptions {
56
+ source: FindingSource
57
+ scope?: string | null
58
+ runId?: string
59
+ }
60
+
61
+ export interface ReadReportOptions {
62
+ includeResolved?: boolean
63
+ source?: FindingSource
64
+ scope?: string | null
65
+ }
66
+
67
+ const identity = (f: Finding) =>
68
+ `${f.code}|${f.subjectKey}|${f.propertyAlias ?? ''}|${f.culture ?? ''}`
69
+
70
+ /** Records a run: new findings inserted, seen ones refreshed, absent ones resolved. Returns the run id. */
71
+ export async function writeReport(
72
+ db: Db,
73
+ findings: readonly Finding[],
74
+ options: WriteReportOptions,
75
+ ): Promise<string> {
76
+ const { source } = options
77
+ const scope = options.scope ?? null
78
+ const runId = options.runId ?? crypto.randomUUID()
79
+ const now = DbDate.toDb(new Date())
80
+ const open = await readReport(db, { source, scope })
81
+ const openByIdentity = new Map(open.map((f) => [identity(f), f]))
82
+ const seen = new Set<string>()
83
+ for (const finding of findings) {
84
+ const key = identity(finding)
85
+ seen.add(key)
86
+ const existing = openByIdentity.get(key)
87
+ if (existing) {
88
+ await db.exec(
89
+ 'UPDATE change_report SET run_id = ?, kind = ?, subject_name = ?, message = ?, link = ?, last_seen = ? WHERE id = ?',
90
+ [runId, finding.kind, finding.subjectName, finding.message, finding.link, now, existing.id],
91
+ )
92
+ } else {
93
+ await db.exec(
94
+ `INSERT INTO change_report (run_id, source, scope, kind, code, subject_type, subject_key, subject_name, property_alias, culture, message, link, status, first_seen, last_seen)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
96
+ [
97
+ runId,
98
+ source,
99
+ scope,
100
+ finding.kind,
101
+ finding.code,
102
+ finding.subjectType,
103
+ finding.subjectKey,
104
+ finding.subjectName,
105
+ finding.propertyAlias,
106
+ finding.culture,
107
+ finding.message,
108
+ finding.link,
109
+ now,
110
+ now,
111
+ ],
112
+ )
113
+ }
114
+ }
115
+ for (const [key, existing] of openByIdentity) {
116
+ if (seen.has(key)) continue
117
+ await db.exec("UPDATE change_report SET status = 'resolved', resolved_at = ? WHERE id = ?", [
118
+ now,
119
+ existing.id,
120
+ ])
121
+ }
122
+ return runId
123
+ }
124
+
125
+ export async function readReport(
126
+ db: Db,
127
+ options: ReadReportOptions = {},
128
+ ): Promise<ReportedFinding[]> {
129
+ const where: string[] = []
130
+ const params: unknown[] = []
131
+ if (!options.includeResolved) where.push("status = 'open'")
132
+ if (options.source !== undefined) {
133
+ where.push('source = ?')
134
+ params.push(options.source)
135
+ }
136
+ // `scope` is meaningful when null, so only an explicit key filters.
137
+ if ('scope' in options) {
138
+ const scope = options.scope ?? null
139
+ where.push(scope === null ? 'scope IS NULL' : 'scope = ?')
140
+ if (scope !== null) params.push(scope)
141
+ }
142
+ const rows = await db.query(
143
+ `SELECT id, run_id, source, scope, kind, code, subject_type, subject_key, subject_name, property_alias, culture, message, link, status, first_seen, last_seen, resolved_at
144
+ FROM change_report ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`,
145
+ params,
146
+ )
147
+ return rows.map((row) => ({
148
+ id: Number(row.id),
149
+ runId: String(row.run_id),
150
+ source: String(row.source) as FindingSource,
151
+ scope: (row.scope as string | null) ?? null,
152
+ kind: String(row.kind) as FindingKind,
153
+ code: String(row.code),
154
+ subjectType: String(row.subject_type) as Finding['subjectType'],
155
+ subjectKey: String(row.subject_key),
156
+ subjectName: String(row.subject_name ?? ''),
157
+ propertyAlias: (row.property_alias as string | null) ?? null,
158
+ culture: (row.culture as string | null) ?? null,
159
+ message: String(row.message),
160
+ link: (row.link as string | null) ?? null,
161
+ status: String(row.status) as 'open' | 'resolved',
162
+ firstSeen: DbDate.fromDb(row.first_seen) ?? new Date(0),
163
+ lastSeen: DbDate.fromDb(row.last_seen) ?? new Date(0),
164
+ resolvedAt: DbDate.fromDb(row.resolved_at) ?? null,
165
+ }))
166
+ }
package/src/cluster.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Multi-node plumbing: node identity in `server`, and the polled
3
+ * `cache_instruction` table that tells other nodes what changed.
4
+ * docs/09-schema-as-code.md, "Cache coherence".
5
+ */
6
+ import type { Db } from './database.ts'
7
+ import { DbDate } from './dialect.ts'
8
+
9
+ /**
10
+ * `views` is a code change, not a data one: a template was written, so every
11
+ * node has to look at its views tree again. The other two invalidate the
12
+ * published cache; this one makes a node take a new snapshot.
13
+ */
14
+ export type CacheInstructionKind = 'schema' | 'content' | 'components'
15
+
16
+ export interface CacheInstruction {
17
+ id: number
18
+ kind: CacheInstructionKind
19
+ payload: Record<string, unknown>
20
+ createdBy: string | null
21
+ }
22
+
23
+ export async function appendCacheInstruction(
24
+ db: Db,
25
+ instruction: { kind: CacheInstructionKind; payload?: Record<string, unknown>; by?: string },
26
+ ): Promise<void> {
27
+ await db.exec(
28
+ 'INSERT INTO cache_instruction (kind, payload, created_at, created_by) VALUES (?, ?, ?, ?)',
29
+ [
30
+ instruction.kind,
31
+ JSON.stringify(instruction.payload ?? {}),
32
+ DbDate.toDb(new Date()),
33
+ instruction.by ?? null,
34
+ ],
35
+ )
36
+ }
37
+
38
+ /** The highest instruction id, so a fresh node ignores history. */
39
+ export async function latestCacheInstructionId(db: Db): Promise<number> {
40
+ const rows = await db.query<{ n: number | null }>('SELECT MAX(id) AS n FROM cache_instruction')
41
+ return Number(rows[0]?.n ?? 0)
42
+ }
43
+
44
+ export async function cacheInstructionsAfter(db: Db, afterId: number): Promise<CacheInstruction[]> {
45
+ const rows = await db.query(
46
+ 'SELECT id, kind, payload, created_by FROM cache_instruction WHERE id > ? ORDER BY id',
47
+ [afterId],
48
+ )
49
+ return rows.map((row) => ({
50
+ id: Number(row.id),
51
+ kind: String(row.kind) as CacheInstructionKind,
52
+ payload: row.payload ? (JSON.parse(String(row.payload)) as Record<string, unknown>) : {},
53
+ createdBy: (row.created_by as string | null) ?? null,
54
+ }))
55
+ }
56
+
57
+ export interface ServerIdentity {
58
+ nodeId: string
59
+ version: string
60
+ revision: string
61
+ /** `all`, `api` or `web`: what this node serves, so the cluster can be read back by role. */
62
+ role?: string
63
+ }
64
+
65
+ /** Records this node in `server`; called at boot and on each poll. */
66
+ export async function touchServer(db: Db, identity: ServerIdentity): Promise<void> {
67
+ const now = DbDate.toDb(new Date())
68
+ const role = identity.role ?? null
69
+ const existing = await db.query<{ node_id: string }>(
70
+ 'SELECT node_id FROM server WHERE node_id = ?',
71
+ [identity.nodeId],
72
+ )
73
+ if (existing[0]) {
74
+ await db.exec(
75
+ 'UPDATE server SET version = ?, revision = ?, role = ?, last_seen = ? WHERE node_id = ?',
76
+ [identity.version, identity.revision, role, now, identity.nodeId],
77
+ )
78
+ } else {
79
+ await db.exec(
80
+ 'INSERT INTO server (node_id, version, revision, role, last_seen) VALUES (?, ?, ?, ?, ?)',
81
+ [identity.nodeId, identity.version, identity.revision, role, now],
82
+ )
83
+ }
84
+ }
85
+
86
+ export interface LiveNode {
87
+ nodeId: string
88
+ /** Null for a row written before roles were recorded. */
89
+ role: string | null
90
+ version: string | null
91
+ revision: string | null
92
+ lastSeen: Date
93
+ }
94
+
95
+ /** The nodes that have polled within `seenWithinMs`: the cluster as it stands. */
96
+ export async function liveNodes(db: Db, options: { seenWithinMs: number }): Promise<LiveNode[]> {
97
+ const rows = await db.query(
98
+ 'SELECT node_id, role, version, revision, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
99
+ [DbDate.toDb(new Date(Date.now() - options.seenWithinMs))],
100
+ )
101
+ return rows.map((row) => ({
102
+ nodeId: String(row.node_id),
103
+ role: (row.role as string | null) ?? null,
104
+ version: (row.version as string | null) ?? null,
105
+ revision: (row.revision as string | null) ?? null,
106
+ lastSeen: DbDate.fromDb(row.last_seen) as Date,
107
+ }))
108
+ }
@@ -0,0 +1,121 @@
1
+ /** Driver-agnostic database handle. */
2
+ import { Database as BunSqlite } from 'bun:sqlite'
3
+ import { type Dialect, type DialectName, dialectFor } from './dialect.ts'
4
+ import { createLockManager, type LockManager } from './locks.ts'
5
+
6
+ export interface Db {
7
+ readonly dialect: Dialect
8
+ readonly locks: LockManager
9
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
10
+ exec(sql: string, params?: unknown[]): Promise<void>
11
+ transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T>
12
+ close(): Promise<void>
13
+ }
14
+
15
+ export interface ConnectOptions {
16
+ dialect?: DialectName
17
+ /** Postgres connection pool size. */
18
+ poolSize?: number
19
+ /** SQLite file path, or ':memory:'. Ignored for Postgres. */
20
+ file?: string
21
+ /** Postgres connection string. Ignored for SQLite. */
22
+ url?: string
23
+ }
24
+
25
+ class SqliteDb implements Db {
26
+ readonly dialect = dialectFor('sqlite')
27
+ readonly locks: LockManager
28
+ #db: BunSqlite
29
+ #inTransaction: boolean
30
+
31
+ constructor(db: BunSqlite, locks?: LockManager, inTransaction = false) {
32
+ this.#db = db
33
+ this.locks = locks ?? createLockManager(this)
34
+ this.#inTransaction = inTransaction
35
+ }
36
+
37
+ async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
38
+ return this.#db.query(this.dialect.render(sql)).all(...(params as never[])) as T[]
39
+ }
40
+
41
+ async exec(sql: string, params: unknown[] = []): Promise<void> {
42
+ this.#db.query(this.dialect.render(sql)).run(...(params as never[]))
43
+ }
44
+
45
+ async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
46
+ if (this.#inTransaction) return fn(this)
47
+ this.#db.exec('BEGIN')
48
+ try {
49
+ const result = await fn(new SqliteDb(this.#db, this.locks, true))
50
+ this.#db.exec('COMMIT')
51
+ return result
52
+ } catch (error) {
53
+ this.#db.exec('ROLLBACK')
54
+ throw error
55
+ }
56
+ }
57
+
58
+ async close(): Promise<void> {
59
+ this.#db.close()
60
+ }
61
+ }
62
+
63
+ interface SqlTag {
64
+ unsafe(sql: string, params?: unknown[]): Promise<unknown>
65
+ begin<T>(fn: (tx: SqlTag) => Promise<T>): Promise<T>
66
+ close(): Promise<void>
67
+ }
68
+
69
+ class PostgresDb implements Db {
70
+ readonly dialect = dialectFor('postgres')
71
+ readonly locks: LockManager
72
+ #sql: SqlTag
73
+ #inTransaction: boolean
74
+
75
+ constructor(sql: SqlTag, locks?: LockManager, inTransaction = false) {
76
+ this.#sql = sql
77
+ this.locks = locks ?? createLockManager(this)
78
+ this.#inTransaction = inTransaction
79
+ }
80
+
81
+ async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
82
+ return (await this.#sql.unsafe(this.dialect.render(sql), params)) as T[]
83
+ }
84
+
85
+ async exec(sql: string, params: unknown[] = []): Promise<void> {
86
+ await this.#sql.unsafe(this.dialect.render(sql), params)
87
+ }
88
+
89
+ /** Nested calls join the enclosing transaction, as they do on SQLite. */
90
+ async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
91
+ if (this.#inTransaction) return fn(this)
92
+ return this.#sql.begin(async (tx) => fn(new PostgresDb(tx, this.locks, true)))
93
+ }
94
+
95
+ async close(): Promise<void> {
96
+ await this.#sql.close()
97
+ }
98
+ }
99
+
100
+ export function resolveDialectName(options: ConnectOptions = {}): DialectName {
101
+ return options.dialect ?? (Bun.env.CROMBOLO_DB as DialectName | undefined) ?? 'sqlite'
102
+ }
103
+
104
+ export async function connect(options: ConnectOptions = {}): Promise<Db> {
105
+ if (resolveDialectName(options) === 'postgres') {
106
+ const url = options.url ?? Bun.env.CROMBOLO_POSTGRES_URL ?? Bun.env.DATABASE_URL
107
+ if (!url) throw new Error('Postgres selected but no connection string was provided.')
108
+ const { SQL } = await import('bun')
109
+ // A modest pool: many short-lived connections exhaust Postgres' client limit,
110
+ // and a single process does not need more.
111
+ const sql = new SQL({ url, max: options.poolSize ?? 5 }) as unknown as SqlTag
112
+ const db = new PostgresDb(sql)
113
+ await db.exec('CREATE EXTENSION IF NOT EXISTS citext')
114
+ return db
115
+ }
116
+ const sqlite = new BunSqlite(options.file ?? ':memory:', { create: true })
117
+ sqlite.exec('PRAGMA journal_mode = WAL')
118
+ sqlite.exec('PRAGMA foreign_keys = ON')
119
+ sqlite.exec('PRAGMA busy_timeout = 5000')
120
+ return new SqliteDb(sqlite)
121
+ }
package/src/dialect.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The single seam between the services and SQL. Queries are written once with
3
+ * `?` placeholders and rendered per dialect, so no service ever needs to know
4
+ * which database it is talking to.
5
+ */
6
+ export type DialectName = 'sqlite' | 'postgres'
7
+
8
+ export interface ColumnTypes {
9
+ identity: string
10
+ integer: string
11
+ bigint: string
12
+ boolean: string
13
+ uuid: string
14
+ /** Case-insensitive text. Umbraco compares aliases and logins case-insensitively. */
15
+ textCi: string
16
+ text: string
17
+ varchar: (n: number) => string
18
+ timestamp: string
19
+ numeric: string
20
+ blob: string
21
+ }
22
+
23
+ export interface Dialect {
24
+ readonly name: DialectName
25
+ readonly types: ColumnTypes
26
+ placeholder(index: number): string
27
+ quote(identifier: string): string
28
+ /** Rewrites `?` placeholders into the dialect's own form. */
29
+ render(sql: string): string
30
+ now(): string
31
+ boolValue(value: boolean): unknown
32
+ /** Appended to a SELECT that must block a concurrent state change; empty where the engine is single-writer. */
33
+ readonly forShare: string
34
+ readonly forUpdate: string
35
+ /**
36
+ * SQL that moves a table's identity past rows inserted with explicit ids, or
37
+ * undefined where the engine does that itself.
38
+ */
39
+ syncIdentity(table: string): string | undefined
40
+ }
41
+
42
+ const RESERVED_RENDER = /\?/g
43
+
44
+ export const sqliteDialect: Dialect = {
45
+ name: 'sqlite',
46
+ types: {
47
+ identity: 'INTEGER PRIMARY KEY AUTOINCREMENT',
48
+ integer: 'INTEGER',
49
+ bigint: 'INTEGER',
50
+ boolean: 'INTEGER',
51
+ uuid: 'TEXT',
52
+ // COLLATE NOCASE is how Umbraco gets case-insensitive aliases on SQLite.
53
+ textCi: 'TEXT COLLATE NOCASE',
54
+ text: 'TEXT',
55
+ varchar: (n) => `VARCHAR(${n}) COLLATE NOCASE`,
56
+ timestamp: 'TEXT',
57
+ // REAL would be lossy for decimal property values.
58
+ numeric: 'TEXT',
59
+ blob: 'BLOB',
60
+ },
61
+ placeholder: () => '?',
62
+ quote: (id) => `"${id.replaceAll('"', '""')}"`,
63
+ render: (sql) => sql,
64
+ now: () => "strftime('%Y-%m-%dT%H:%M:%fZ','now')",
65
+ boolValue: (value) => (value ? 1 : 0),
66
+ forShare: '',
67
+ forUpdate: '',
68
+ syncIdentity: () => undefined,
69
+ }
70
+
71
+ export const postgresDialect: Dialect = {
72
+ name: 'postgres',
73
+ types: {
74
+ identity: 'INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY',
75
+ integer: 'INTEGER',
76
+ bigint: 'BIGINT',
77
+ boolean: 'BOOLEAN',
78
+ uuid: 'UUID',
79
+ // Postgres collates case-sensitively by default; citext restores Umbraco's semantics.
80
+ textCi: 'CITEXT',
81
+ text: 'TEXT',
82
+ varchar: (n) => `VARCHAR(${n})`,
83
+ timestamp: 'TIMESTAMPTZ',
84
+ numeric: 'NUMERIC',
85
+ blob: 'BYTEA',
86
+ },
87
+ placeholder: (index) => `$${index}`,
88
+ quote: (id) => `"${id.replaceAll('"', '""')}"`,
89
+ render(sql) {
90
+ let i = 0
91
+ return sql.replace(RESERVED_RENDER, () => `$${++i}`)
92
+ },
93
+ now: () => 'now()',
94
+ boolValue: (value) => value,
95
+ forShare: 'FOR SHARE',
96
+ forUpdate: 'FOR UPDATE',
97
+ syncIdentity: (table) =>
98
+ `SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${table}))`,
99
+ }
100
+
101
+ export function dialectFor(name: DialectName): Dialect {
102
+ return name === 'sqlite' ? sqliteDialect : postgresDialect
103
+ }
104
+
105
+ /**
106
+ * Timestamps are written as ISO strings in both dialects — SQLite stores TEXT,
107
+ * and Postgres parses ISO into timestamptz — but they read back differently
108
+ * (string vs Date), so every read goes through `fromDb`.
109
+ */
110
+ export const DbDate = {
111
+ toDb(value: Date): string {
112
+ return value.toISOString()
113
+ },
114
+ fromDb(value: unknown): Date | undefined {
115
+ if (value === null || value === undefined) return undefined
116
+ if (value instanceof Date) return value
117
+ if (typeof value === 'string') {
118
+ const parsed = new Date(value)
119
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed
120
+ }
121
+ return undefined
122
+ },
123
+ }
124
+
125
+ /** SQLite has no boolean type, so a 0/1 integer comes back for one dialect only. */
126
+ export function fromDbBool(value: unknown): boolean {
127
+ return value === true || value === 1 || value === '1' || value === 't'
128
+ }