@crombolo/data 0.10.0 → 0.12.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/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/audit.ts +291 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +80 -17
- package/src/dialect.ts +24 -0
- package/src/index.ts +20 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/data-types.ts +38 -0
- package/src/repositories/delivery-member-tokens.ts +110 -0
- package/src/repositories/dictionary.ts +18 -0
- package/src/repositories/documents.ts +443 -88
- package/src/repositories/dynamic-roots.ts +151 -0
- package/src/repositories/form-entries.ts +32 -11
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +26 -1
- package/src/repositories/nodes.ts +43 -7
- package/src/repositories/public-access.ts +30 -0
- package/src/repositories/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +14 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/seed.ts +13 -2
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- package/src/url-options.ts +61 -0
|
@@ -27,10 +27,26 @@ import type { Db } from '../database.ts'
|
|
|
27
27
|
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
28
28
|
import { pendingUntil } from '../pending.ts'
|
|
29
29
|
import type { NodeSchemaState } from '../schema-state.ts'
|
|
30
|
+
import { queueContentTypesForSearch, queueEverythingForSearch } from '../search.ts'
|
|
30
31
|
import { NodeRepository } from './nodes.ts'
|
|
31
32
|
|
|
32
33
|
const GROUP_TYPE_NAMES = ['Group', 'Tab'] as const
|
|
33
34
|
|
|
35
|
+
/** The live properties of a type, by alias, variation and storage: what its items are indexed by. */
|
|
36
|
+
async function propertySignature(db: Db, contentTypeNodeId: number): Promise<string> {
|
|
37
|
+
const rows = await db.query<Record<string, unknown>>(
|
|
38
|
+
`SELECT p.id, p.alias, p.variations, p.data_type_id, p.searchable FROM property_type p
|
|
39
|
+
WHERE p.content_type_id = ? AND p.retired_at IS NULL ORDER BY p.id`,
|
|
40
|
+
[contentTypeNodeId],
|
|
41
|
+
)
|
|
42
|
+
return rows
|
|
43
|
+
.map(
|
|
44
|
+
(row) =>
|
|
45
|
+
`${row.id}:${row.alias}:${row.variations}:${row.data_type_id}:${fromDbBool(row.searchable)}`,
|
|
46
|
+
)
|
|
47
|
+
.join('|')
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
export interface SaveContentTypeOptions {
|
|
35
51
|
userId?: number
|
|
36
52
|
/** The schema state that introduced anything created by this save. */
|
|
@@ -228,6 +244,9 @@ export class ContentTypeRepository {
|
|
|
228
244
|
? ((await repo.nodes.byKey(aggregate.parentKey))?.id ?? SystemNodes.Root)
|
|
229
245
|
: SystemNodes.Root
|
|
230
246
|
|
|
247
|
+
// What the search index holds depends on which properties are live and what
|
|
248
|
+
// they are called, so a change to either has the type's items re-indexed.
|
|
249
|
+
const propertiesBefore = existing ? await propertySignature(tx, existing.id) : ''
|
|
231
250
|
let nodeId: number
|
|
232
251
|
if (existing) {
|
|
233
252
|
nodeId = existing.id
|
|
@@ -361,13 +380,25 @@ export class ContentTypeRepository {
|
|
|
361
380
|
WHERE id = ?`,
|
|
362
381
|
[...columns, Number(found[0].id)],
|
|
363
382
|
)
|
|
383
|
+
// Only a schema sync says; a backoffice save leaves it as it was.
|
|
384
|
+
if (property.searchable !== undefined)
|
|
385
|
+
await tx.exec('UPDATE property_type SET searchable = ? WHERE id = ?', [
|
|
386
|
+
bool(property.searchable),
|
|
387
|
+
Number(found[0].id),
|
|
388
|
+
])
|
|
364
389
|
} else {
|
|
365
390
|
await tx.exec(
|
|
366
391
|
`INSERT INTO property_type
|
|
367
392
|
(unique_id, content_type_id, data_type_id, property_type_group_id, alias, name, description, sort_order,
|
|
368
|
-
mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id)
|
|
369
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
370
|
-
[
|
|
393
|
+
mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id, searchable)
|
|
394
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
395
|
+
[
|
|
396
|
+
key,
|
|
397
|
+
nodeId,
|
|
398
|
+
...columns,
|
|
399
|
+
options.sinceStateId ?? null,
|
|
400
|
+
bool(property.searchable !== false),
|
|
401
|
+
],
|
|
371
402
|
)
|
|
372
403
|
}
|
|
373
404
|
}
|
|
@@ -451,6 +482,11 @@ export class ContentTypeRepository {
|
|
|
451
482
|
)
|
|
452
483
|
}
|
|
453
484
|
|
|
485
|
+
if (existing && propertiesBefore !== (await propertySignature(tx, nodeId))) {
|
|
486
|
+
if (aggregate.isElement) await queueEverythingForSearch(tx)
|
|
487
|
+
else await queueContentTypesForSearch(tx, [nodeId])
|
|
488
|
+
}
|
|
489
|
+
|
|
454
490
|
const saved = await repo.byKey(aggregate.key)
|
|
455
491
|
if (!saved) throw new Error('Content type could not be read back after saving.')
|
|
456
492
|
return saved
|
|
@@ -739,7 +775,7 @@ export class ContentTypeRepository {
|
|
|
739
775
|
p.validation_reg_exp, p.validation_reg_exp_message, p.label_on_top, p.variations,
|
|
740
776
|
dtn.unique_id AS data_type_key, g.unique_id AS container_key,
|
|
741
777
|
p.since_version, ss.version AS since_v, ss.revision AS since_r,
|
|
742
|
-
m.member_can_edit, m.member_can_view, m.is_sensitive
|
|
778
|
+
m.member_can_edit, m.member_can_view, m.is_sensitive, p.searchable
|
|
743
779
|
FROM property_type p
|
|
744
780
|
JOIN node dtn ON dtn.id = p.data_type_id
|
|
745
781
|
LEFT JOIN property_type_group g ON g.id = p.property_type_group_id
|
|
@@ -766,6 +802,7 @@ export class ContentTypeRepository {
|
|
|
766
802
|
labelOnTop: fromDbBool(row.label_on_top),
|
|
767
803
|
sinceVersion: (row.since_version as string | null) ?? null,
|
|
768
804
|
pending: this.#pending(row),
|
|
805
|
+
...(fromDbBool(row.searchable) ? {} : { searchable: false }),
|
|
769
806
|
...(this.#kind === 'member'
|
|
770
807
|
? {
|
|
771
808
|
memberCanEdit: fromDbBool(row.member_can_edit),
|
|
@@ -121,6 +121,44 @@ export class DataTypeRepository {
|
|
|
121
121
|
return { total: items.length, items: items.slice(paging.skip, paging.skip + paging.take) }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** Whether any current value is saved through this data type; undefined when there is no such type. */
|
|
125
|
+
async isUsed(key: string): Promise<boolean | undefined> {
|
|
126
|
+
const node = await this.#nodes.byKey(key)
|
|
127
|
+
if (!node || node.objectType !== ObjectTypes.DataType) return undefined
|
|
128
|
+
return this.#hasValues('p.data_type_id = ?', [node.id])
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether any current value is saved for the property `alias` of a content type;
|
|
133
|
+
* undefined when the type or the property does not exist.
|
|
134
|
+
*/
|
|
135
|
+
async isPropertyUsed(contentTypeKey: string, alias: string): Promise<boolean | undefined> {
|
|
136
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
137
|
+
`SELECT p.id FROM property_type p
|
|
138
|
+
JOIN node ctn ON ctn.id = p.content_type_id
|
|
139
|
+
WHERE ctn.unique_id = ? AND p.alias = ? AND p.retired_at IS NULL`,
|
|
140
|
+
[normaliseUuid(contentTypeKey), alias],
|
|
141
|
+
)
|
|
142
|
+
const property = rows[0]
|
|
143
|
+
if (!property) return undefined
|
|
144
|
+
return this.#hasValues('p.id = ?', [Number(property.id)])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A current value that holds something: an empty string or an all-null row is not a use. */
|
|
148
|
+
async #hasValues(where: string, params: unknown[]): Promise<boolean> {
|
|
149
|
+
const rows = await this.#db.query(
|
|
150
|
+
`SELECT 1 AS used FROM property_value v
|
|
151
|
+
JOIN property_type p ON p.id = v.property_type_id
|
|
152
|
+
WHERE ${where} AND v.is_current = ? AND p.retired_at IS NULL
|
|
153
|
+
AND (v.int_value IS NOT NULL OR v.decimal_value IS NOT NULL OR v.date_value IS NOT NULL
|
|
154
|
+
OR (v.varchar_value IS NOT NULL AND v.varchar_value <> '')
|
|
155
|
+
OR (v.text_value IS NOT NULL AND v.text_value <> ''))
|
|
156
|
+
LIMIT 1`,
|
|
157
|
+
[...params, this.#db.dialect.boolValue(true)],
|
|
158
|
+
)
|
|
159
|
+
return rows.length > 0
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
/** Property types using this data type, with the type each belongs to. */
|
|
125
163
|
async referencedBy(key: string): Promise<
|
|
126
164
|
Array<{
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Delivery API's member tokens. A token is random and opaque; only its
|
|
3
|
+
* SHA-256 is kept, so the table cannot be replayed from a backup.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
6
|
+
import type { Db } from '../database.ts'
|
|
7
|
+
import { DbDate } from '../dialect.ts'
|
|
8
|
+
|
|
9
|
+
export type MemberTokenKind = 'code' | 'access' | 'refresh'
|
|
10
|
+
|
|
11
|
+
export interface MemberToken {
|
|
12
|
+
kind: MemberTokenKind
|
|
13
|
+
memberKey: string
|
|
14
|
+
clientId: string
|
|
15
|
+
scopes: string[]
|
|
16
|
+
redirectUri: string | null
|
|
17
|
+
codeChallenge: string | null
|
|
18
|
+
expiresAt: Date
|
|
19
|
+
redeemedAt: Date | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A new token for a client to hold, and the hash it is stored under. Pure apart from the randomness. */
|
|
23
|
+
export function newToken(): { token: string; hash: string } {
|
|
24
|
+
const token = randomBytes(32).toString('base64url')
|
|
25
|
+
return { token, hash: hashToken(token) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
|
|
29
|
+
|
|
30
|
+
/** PKCE S256: the verifier's SHA-256, base64url, against the challenge. Pure. */
|
|
31
|
+
export const pkceMatches = (verifier: string, challenge: string): boolean =>
|
|
32
|
+
createHash('sha256').update(verifier).digest('base64url') === challenge
|
|
33
|
+
|
|
34
|
+
export class DeliveryMemberTokenRepository {
|
|
35
|
+
#db: Db
|
|
36
|
+
constructor(db: Db) {
|
|
37
|
+
this.#db = db
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async issue(
|
|
41
|
+
token: Omit<MemberToken, 'redeemedAt' | 'expiresAt'> & { lifetimeMs: number },
|
|
42
|
+
): Promise<{ token: string; expiresAt: Date }> {
|
|
43
|
+
const { token: value, hash } = newToken()
|
|
44
|
+
const now = new Date()
|
|
45
|
+
const expiresAt = new Date(now.getTime() + token.lifetimeMs)
|
|
46
|
+
await this.#db.exec(
|
|
47
|
+
`INSERT INTO delivery_member_token
|
|
48
|
+
(hash, kind, member_key, client_id, scopes, redirect_uri, code_challenge, expires_at, create_date)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
50
|
+
[
|
|
51
|
+
hash,
|
|
52
|
+
token.kind,
|
|
53
|
+
token.memberKey,
|
|
54
|
+
token.clientId,
|
|
55
|
+
token.scopes.join(' '),
|
|
56
|
+
token.redirectUri,
|
|
57
|
+
token.codeChallenge,
|
|
58
|
+
DbDate.toDb(expiresAt),
|
|
59
|
+
DbDate.toDb(now),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
return { token: value, expiresAt }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The token a client presents, whatever its state; the caller judges it. */
|
|
66
|
+
async find(token: string): Promise<MemberToken | undefined> {
|
|
67
|
+
const rows = await this.#db.query('SELECT * FROM delivery_member_token WHERE hash = ?', [
|
|
68
|
+
hashToken(token),
|
|
69
|
+
])
|
|
70
|
+
const row = rows[0]
|
|
71
|
+
if (!row) return undefined
|
|
72
|
+
return {
|
|
73
|
+
kind: String(row.kind) as MemberTokenKind,
|
|
74
|
+
memberKey: String(row.member_key),
|
|
75
|
+
clientId: String(row.client_id),
|
|
76
|
+
scopes: String(row.scopes ?? '')
|
|
77
|
+
.split(' ')
|
|
78
|
+
.filter(Boolean),
|
|
79
|
+
redirectUri: (row.redirect_uri as string | null) ?? null,
|
|
80
|
+
codeChallenge: (row.code_challenge as string | null) ?? null,
|
|
81
|
+
expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
|
|
82
|
+
redeemedAt: DbDate.fromDb(row.redeemed_at) ?? null,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Marks a code or refresh token used; false when it already was, so only one redemption wins. */
|
|
87
|
+
async redeem(token: string): Promise<boolean> {
|
|
88
|
+
const rows = await this.#db.query<{ hash: string }>(
|
|
89
|
+
'UPDATE delivery_member_token SET redeemed_at = ? WHERE hash = ? AND redeemed_at IS NULL RETURNING hash',
|
|
90
|
+
[DbDate.toDb(new Date()), hashToken(token)],
|
|
91
|
+
)
|
|
92
|
+
return rows.length > 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async revoke(token: string): Promise<void> {
|
|
96
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE hash = ?', [hashToken(token)])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every token a member holds, as signing out ends them. */
|
|
100
|
+
async revokeMember(memberKey: string): Promise<void> {
|
|
101
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE member_key = ?', [memberKey])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Expired tokens, which nothing will accept again. */
|
|
105
|
+
async purgeExpired(): Promise<void> {
|
|
106
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE expires_at < ?', [
|
|
107
|
+
DbDate.toDb(new Date()),
|
|
108
|
+
])
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -240,6 +240,24 @@ export class DictionaryRepository {
|
|
|
240
240
|
})
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/** Every item's key, name and parent's name, for a choice list drawn from the dictionary. */
|
|
244
|
+
async outline(): Promise<Array<{ key: string; name: string; parentName: string | null }>> {
|
|
245
|
+
const rows = await this.#db.query<{
|
|
246
|
+
key: string
|
|
247
|
+
item_key: string
|
|
248
|
+
parent_name: string | null
|
|
249
|
+
}>(
|
|
250
|
+
`SELECT d.key, d.item_key, p.item_key AS parent_name FROM dictionary_item d
|
|
251
|
+
LEFT JOIN dictionary_item p ON p.key = d.parent_key
|
|
252
|
+
ORDER BY d.item_key`,
|
|
253
|
+
)
|
|
254
|
+
return rows.map((row) => ({
|
|
255
|
+
key: normaliseUuid(String(row.key)),
|
|
256
|
+
name: String(row.item_key),
|
|
257
|
+
parentName: row.parent_name === null ? null : String(row.parent_name),
|
|
258
|
+
}))
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
/** Every translation, keyed by item name then iso code, for rendering. */
|
|
244
262
|
async translations(): Promise<Map<string, Map<string, string>>> {
|
|
245
263
|
const rows = await this.#db.query<{ item_key: string; iso_code: string; value: string }>(
|