@crombolo/data 0.14.1 → 0.15.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/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/repositories/remote-jobs.ts +191 -0
- package/src/repositories/transfer-runs.ts +8 -3
- package/src/schema/plan.ts +2 -0
- package/src/schema/remote.ts +76 -0
- package/src/schema/seed.ts +2 -0
- package/src/setup.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.15.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from './repositories/notifications.ts'
|
|
|
33
33
|
export * from './repositories/public-access.ts'
|
|
34
34
|
export * from './repositories/redirects.ts'
|
|
35
35
|
export * from './repositories/references.ts'
|
|
36
|
+
export * from './repositories/remote-jobs.ts'
|
|
36
37
|
export * from './repositories/tags.ts'
|
|
37
38
|
export * from './repositories/templates.ts'
|
|
38
39
|
export * from './repositories/transfer-answers.ts'
|
|
@@ -67,6 +68,7 @@ export * from './schema/plan.ts'
|
|
|
67
68
|
export * from './schema/property-searchable.ts'
|
|
68
69
|
export * from './schema/public-access.ts'
|
|
69
70
|
export * from './schema/recycle-bin.ts'
|
|
71
|
+
export * from './schema/remote.ts'
|
|
70
72
|
export * from './schema/rich-text-ui.ts'
|
|
71
73
|
export * from './schema/search-index.ts'
|
|
72
74
|
export * from './schema/seed.ts'
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls and pushes through the remote protocol (story 107): one row per job,
|
|
3
|
+
* read by whichever node a client polls, written by the node running it.
|
|
4
|
+
*/
|
|
5
|
+
import type { Db } from '../database.ts'
|
|
6
|
+
import { DbDate } from '../dialect.ts'
|
|
7
|
+
|
|
8
|
+
export type RemoteJobKind = 'pull' | 'push'
|
|
9
|
+
export type RemoteJobState =
|
|
10
|
+
| 'queued'
|
|
11
|
+
| 'running'
|
|
12
|
+
| 'approval-required'
|
|
13
|
+
| 'done'
|
|
14
|
+
| 'failed'
|
|
15
|
+
| 'refused'
|
|
16
|
+
|
|
17
|
+
export interface RemoteJob {
|
|
18
|
+
id: string
|
|
19
|
+
kind: RemoteJobKind
|
|
20
|
+
environment: string
|
|
21
|
+
requestedBy: string
|
|
22
|
+
requestedByName: string
|
|
23
|
+
options: Record<string, unknown>
|
|
24
|
+
state: RemoteJobState
|
|
25
|
+
stage: string | null
|
|
26
|
+
progress: number | null
|
|
27
|
+
fileKey: string | null
|
|
28
|
+
fileSize: number | null
|
|
29
|
+
sha256: string | null
|
|
30
|
+
problem: { code: string; title: string; detail?: string; items?: string[] } | null
|
|
31
|
+
secret: string | null
|
|
32
|
+
runId: string | null
|
|
33
|
+
decidedBy: string | null
|
|
34
|
+
decidedAt: Date | undefined
|
|
35
|
+
heartbeatAt: Date
|
|
36
|
+
expiresAt: Date
|
|
37
|
+
createdAt: Date
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parse = <T>(value: unknown, fallback: T): T => {
|
|
41
|
+
if (typeof value !== 'string' || value === '') return fallback
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value) as T
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mapJob(row: Record<string, unknown>): RemoteJob {
|
|
50
|
+
const str = (v: unknown) => (v === null || v === undefined ? null : String(v))
|
|
51
|
+
return {
|
|
52
|
+
id: String(row.id),
|
|
53
|
+
kind: String(row.kind) as RemoteJobKind,
|
|
54
|
+
environment: String(row.environment),
|
|
55
|
+
requestedBy: String(row.requested_by),
|
|
56
|
+
requestedByName: String(row.requested_by_name),
|
|
57
|
+
options: parse(row.options, {}),
|
|
58
|
+
state: String(row.state) as RemoteJobState,
|
|
59
|
+
stage: str(row.stage),
|
|
60
|
+
progress: row.progress === null || row.progress === undefined ? null : Number(row.progress),
|
|
61
|
+
fileKey: str(row.file_key),
|
|
62
|
+
fileSize: row.file_size === null || row.file_size === undefined ? null : Number(row.file_size),
|
|
63
|
+
sha256: str(row.sha256),
|
|
64
|
+
problem: parse(row.problem, null),
|
|
65
|
+
secret: str(row.secret),
|
|
66
|
+
runId: str(row.run_id),
|
|
67
|
+
decidedBy: str(row.decided_by),
|
|
68
|
+
decidedAt: DbDate.fromDb(row.decided_at),
|
|
69
|
+
heartbeatAt: DbDate.fromDb(row.heartbeat_at) ?? new Date(0),
|
|
70
|
+
expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
|
|
71
|
+
createdAt: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type RemoteJobUpdate = Partial<
|
|
76
|
+
Pick<
|
|
77
|
+
RemoteJob,
|
|
78
|
+
| 'state'
|
|
79
|
+
| 'stage'
|
|
80
|
+
| 'progress'
|
|
81
|
+
| 'fileKey'
|
|
82
|
+
| 'fileSize'
|
|
83
|
+
| 'sha256'
|
|
84
|
+
| 'problem'
|
|
85
|
+
| 'secret'
|
|
86
|
+
| 'runId'
|
|
87
|
+
| 'decidedBy'
|
|
88
|
+
| 'decidedAt'
|
|
89
|
+
| 'expiresAt'
|
|
90
|
+
>
|
|
91
|
+
>
|
|
92
|
+
|
|
93
|
+
const COLUMNS: Record<keyof RemoteJobUpdate, string> = {
|
|
94
|
+
state: 'state',
|
|
95
|
+
stage: 'stage',
|
|
96
|
+
progress: 'progress',
|
|
97
|
+
fileKey: 'file_key',
|
|
98
|
+
fileSize: 'file_size',
|
|
99
|
+
sha256: 'sha256',
|
|
100
|
+
problem: 'problem',
|
|
101
|
+
secret: 'secret',
|
|
102
|
+
runId: 'run_id',
|
|
103
|
+
decidedBy: 'decided_by',
|
|
104
|
+
decidedAt: 'decided_at',
|
|
105
|
+
expiresAt: 'expires_at',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class RemoteJobRepository {
|
|
109
|
+
#db: Db
|
|
110
|
+
|
|
111
|
+
constructor(db: Db) {
|
|
112
|
+
this.#db = db
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async create(job: {
|
|
116
|
+
id: string
|
|
117
|
+
kind: RemoteJobKind
|
|
118
|
+
environment: string
|
|
119
|
+
requestedBy: string
|
|
120
|
+
requestedByName: string
|
|
121
|
+
options: Record<string, unknown>
|
|
122
|
+
state: RemoteJobState
|
|
123
|
+
expiresAt: Date
|
|
124
|
+
}): Promise<void> {
|
|
125
|
+
const now = DbDate.toDb(new Date())
|
|
126
|
+
await this.#db.exec(
|
|
127
|
+
`INSERT INTO remote_job
|
|
128
|
+
(id, kind, environment, requested_by, requested_by_name, options, state, heartbeat_at, expires_at, create_date)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
130
|
+
[
|
|
131
|
+
job.id,
|
|
132
|
+
job.kind,
|
|
133
|
+
job.environment,
|
|
134
|
+
job.requestedBy,
|
|
135
|
+
job.requestedByName,
|
|
136
|
+
JSON.stringify(job.options),
|
|
137
|
+
job.state,
|
|
138
|
+
now,
|
|
139
|
+
DbDate.toDb(job.expiresAt),
|
|
140
|
+
now,
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async get(id: string): Promise<RemoteJob | undefined> {
|
|
146
|
+
const rows = await this.#db.query('SELECT * FROM remote_job WHERE id = ?', [id])
|
|
147
|
+
return rows[0] ? mapJob(rows[0]) : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Records progress, and that the node running it is still alive. */
|
|
151
|
+
async update(id: string, change: RemoteJobUpdate): Promise<void> {
|
|
152
|
+
const sets: string[] = ['heartbeat_at = ?']
|
|
153
|
+
const values: unknown[] = [DbDate.toDb(new Date())]
|
|
154
|
+
for (const [field, value] of Object.entries(change) as [keyof RemoteJobUpdate, unknown][]) {
|
|
155
|
+
sets.push(`${COLUMNS[field]} = ?`)
|
|
156
|
+
values.push(
|
|
157
|
+
field === 'problem'
|
|
158
|
+
? value === null
|
|
159
|
+
? null
|
|
160
|
+
: JSON.stringify(value)
|
|
161
|
+
: value instanceof Date
|
|
162
|
+
? DbDate.toDb(value)
|
|
163
|
+
: value,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
await this.#db.exec(`UPDATE remote_job SET ${sets.join(', ')} WHERE id = ?`, [...values, id])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Moves a job from one state to another only if it is still in the first; true when it moved. */
|
|
170
|
+
async transition(id: string, from: RemoteJobState, change: RemoteJobUpdate): Promise<boolean> {
|
|
171
|
+
const rows = await this.#db.query<{ id: string }>(
|
|
172
|
+
'UPDATE remote_job SET state = ?, heartbeat_at = ? WHERE id = ? AND state = ? RETURNING id',
|
|
173
|
+
[change.state ?? from, DbDate.toDb(new Date()), id, from],
|
|
174
|
+
)
|
|
175
|
+
if (rows.length === 0) return false
|
|
176
|
+
await this.update(id, change)
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Jobs past their expiry, whose files are to be swept. */
|
|
181
|
+
async expired(now = new Date()): Promise<RemoteJob[]> {
|
|
182
|
+
const rows = await this.#db.query('SELECT * FROM remote_job WHERE expires_at < ?', [
|
|
183
|
+
DbDate.toDb(now),
|
|
184
|
+
])
|
|
185
|
+
return rows.map(mapJob)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async delete(id: string): Promise<void> {
|
|
189
|
+
await this.#db.exec('DELETE FROM remote_job WHERE id = ?', [id])
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -138,14 +138,19 @@ export class TransferRunRepository {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/** Runs, newest first. */
|
|
141
|
-
async recent(limit = 20): Promise<TransferRun[]> {
|
|
141
|
+
async recent(limit = 20, offset = 0): Promise<TransferRun[]> {
|
|
142
142
|
const rows = await this.#db.query(
|
|
143
|
-
'SELECT * FROM content_transfer_run ORDER BY started_at DESC, id DESC LIMIT ?',
|
|
144
|
-
[limit],
|
|
143
|
+
'SELECT * FROM content_transfer_run ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?',
|
|
144
|
+
[limit, offset],
|
|
145
145
|
)
|
|
146
146
|
return rows.map(mapRun)
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
async count(): Promise<number> {
|
|
150
|
+
const rows = await this.#db.query('SELECT COUNT(*) AS n FROM content_transfer_run')
|
|
151
|
+
return Number(rows[0]?.n ?? 0)
|
|
152
|
+
}
|
|
153
|
+
|
|
149
154
|
async changes(runId: string): Promise<TransferChange[]> {
|
|
150
155
|
const rows = await this.#db.query(
|
|
151
156
|
'SELECT * FROM content_transfer_change WHERE run_id = ? ORDER BY id',
|
package/src/schema/plan.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { propertySearchableMigration } from './property-searchable.ts'
|
|
|
40
40
|
import { publicAccessMigration } from './public-access.ts'
|
|
41
41
|
import { recycleBinMigration } from './recycle-bin.ts'
|
|
42
42
|
import { redirectsMigration } from './redirects.ts'
|
|
43
|
+
import { remoteMigration } from './remote.ts'
|
|
43
44
|
import { richTextUiMigration } from './rich-text-ui.ts'
|
|
44
45
|
import { searchIndexMigration } from './search-index.ts'
|
|
45
46
|
import { serverRoleMigration } from './server-role.ts'
|
|
@@ -95,4 +96,5 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
95
96
|
approvalsMigration,
|
|
96
97
|
siteSearchMigration,
|
|
97
98
|
twoFactorMigration,
|
|
99
|
+
remoteMigration,
|
|
98
100
|
])
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 041 — the remote protocol (story 107). docs/remote-protocol.md.
|
|
3
|
+
*
|
|
4
|
+
* `device_authorization` is a sign-in by the OAuth device grant (RFC 8628):
|
|
5
|
+
* the device code's hash, the code a person types, who approved it, and the
|
|
6
|
+
* session its tokens belong to — which is what "signed-in devices" lists and
|
|
7
|
+
* revokes. `remote_job` is a pull or a push: who asked, where it has got to,
|
|
8
|
+
* the file it made or received in the media store, and the approval a
|
|
9
|
+
* protected push may wait for — with its passphrase sealed under
|
|
10
|
+
* `CROMBOLO_SECRET_KEY` meanwhile, since the import cannot run without it. Grants the four remote verbs to the
|
|
11
|
+
* administrators group.
|
|
12
|
+
*/
|
|
13
|
+
import type { Db } from '../database.ts'
|
|
14
|
+
import type { Migration } from '../migrations.ts'
|
|
15
|
+
import { grantBuiltInPermissions } from './seed.ts'
|
|
16
|
+
import { STATE_TWO_FACTOR } from './two-factor.ts'
|
|
17
|
+
|
|
18
|
+
export const STATE_REMOTE = '{2b7e5c91-0041-4f3a-8d26-9c4a1e7b3f51}'
|
|
19
|
+
|
|
20
|
+
export const remoteMigration: Migration = {
|
|
21
|
+
from: STATE_TWO_FACTOR,
|
|
22
|
+
to: STATE_REMOTE,
|
|
23
|
+
name: 'AddRemote',
|
|
24
|
+
kind: 'expand',
|
|
25
|
+
release: '0.14.4',
|
|
26
|
+
async up(db: Db) {
|
|
27
|
+
const t = db.dialect.types
|
|
28
|
+
await db.exec(
|
|
29
|
+
`CREATE TABLE device_authorization (
|
|
30
|
+
id ${t.identity},
|
|
31
|
+
device_code_hash ${t.varchar(64)} NOT NULL UNIQUE,
|
|
32
|
+
user_code ${t.varchar(16)} NOT NULL UNIQUE,
|
|
33
|
+
client_id ${t.varchar(255)} NOT NULL,
|
|
34
|
+
client_name ${t.varchar(255)},
|
|
35
|
+
scope ${t.text} NOT NULL,
|
|
36
|
+
status ${t.varchar(16)} NOT NULL,
|
|
37
|
+
user_id ${t.integer} REFERENCES user_account (id),
|
|
38
|
+
session_id ${t.uuid},
|
|
39
|
+
interval_seconds ${t.integer} NOT NULL,
|
|
40
|
+
last_polled_at ${t.timestamp},
|
|
41
|
+
expires_at ${t.timestamp} NOT NULL,
|
|
42
|
+
decided_at ${t.timestamp},
|
|
43
|
+
create_date ${t.timestamp} NOT NULL
|
|
44
|
+
)`,
|
|
45
|
+
)
|
|
46
|
+
await db.exec(
|
|
47
|
+
'CREATE INDEX ix_device_authorization_session ON device_authorization (session_id)',
|
|
48
|
+
)
|
|
49
|
+
await db.exec(
|
|
50
|
+
`CREATE TABLE remote_job (
|
|
51
|
+
id ${t.varchar(36)} PRIMARY KEY,
|
|
52
|
+
kind ${t.varchar(8)} NOT NULL,
|
|
53
|
+
environment ${t.varchar(64)} NOT NULL,
|
|
54
|
+
requested_by ${t.varchar(36)} NOT NULL,
|
|
55
|
+
requested_by_name ${t.varchar(255)} NOT NULL,
|
|
56
|
+
options ${t.text} NOT NULL,
|
|
57
|
+
state ${t.varchar(24)} NOT NULL,
|
|
58
|
+
stage ${t.varchar(64)},
|
|
59
|
+
progress ${t.integer},
|
|
60
|
+
file_key ${t.text},
|
|
61
|
+
file_size ${t.bigint},
|
|
62
|
+
sha256 ${t.varchar(64)},
|
|
63
|
+
problem ${t.text},
|
|
64
|
+
secret ${t.text},
|
|
65
|
+
run_id ${t.varchar(36)},
|
|
66
|
+
decided_by ${t.varchar(36)},
|
|
67
|
+
decided_at ${t.timestamp},
|
|
68
|
+
heartbeat_at ${t.timestamp} NOT NULL,
|
|
69
|
+
expires_at ${t.timestamp} NOT NULL,
|
|
70
|
+
create_date ${t.timestamp} NOT NULL
|
|
71
|
+
)`,
|
|
72
|
+
)
|
|
73
|
+
await db.exec('CREATE INDEX ix_remote_job_state ON remote_job (state)')
|
|
74
|
+
await grantBuiltInPermissions(db)
|
|
75
|
+
},
|
|
76
|
+
}
|
package/src/schema/seed.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Password hashing is injected rather than imported so this package stays free of
|
|
5
5
|
* a dependency on @crombolo/auth.
|
|
6
6
|
*/
|
|
7
|
+
import { RemotePermissions } from '@crombolo/core'
|
|
7
8
|
import type { Db } from '../database.ts'
|
|
8
9
|
import { DbDate } from '../dialect.ts'
|
|
9
10
|
|
|
@@ -65,6 +66,7 @@ export const BUILT_IN_GROUP_PERMISSIONS: Record<string, readonly string[]> = {
|
|
|
65
66
|
'Umb.Document.PropertyValue.Write',
|
|
66
67
|
...FORM_FULL,
|
|
67
68
|
...ELEMENT_EDIT,
|
|
69
|
+
...Object.values(RemotePermissions),
|
|
68
70
|
],
|
|
69
71
|
editor: [
|
|
70
72
|
'Umb.Document.Create',
|
package/src/setup.ts
CHANGED
|
@@ -55,3 +55,17 @@ export function completeSetup(
|
|
|
55
55
|
}),
|
|
56
56
|
)
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Closes setup without creating an administrator: a machine can administer the
|
|
61
|
+
* site instead (story 034's seeded API client). Answers whether it was pending.
|
|
62
|
+
*/
|
|
63
|
+
export function closeSetup(db: Db): Promise<boolean> {
|
|
64
|
+
return db.locks.withLock(Locks.KeyValues, () =>
|
|
65
|
+
db.transaction(async (tx) => {
|
|
66
|
+
if ((await readSetupState(tx)) !== 'pending') return false
|
|
67
|
+
await writeSetupState(tx, 'complete')
|
|
68
|
+
return true
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
}
|