@fabrika/db 0.0.0-stage → 0.0.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 +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Character comparisons only: no RegExp or String.prototype method that same-process code could replace.
|
|
2
|
+
|
|
3
|
+
const MAX_LENGTH = 63
|
|
4
|
+
|
|
5
|
+
function isIdentifierRange(value: string, start: number, end: number): boolean {
|
|
6
|
+
if (end <= start || end - start > MAX_LENGTH) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
for (let index = start; index < end; index++) {
|
|
10
|
+
const char = value[index] ?? ''
|
|
11
|
+
const lower = char >= 'a' && char <= 'z'
|
|
12
|
+
const digit = char >= '0' && char <= '9'
|
|
13
|
+
if (!(lower || char === '_' || (index > start && digit))) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `^[a-z_][a-z0-9_]*$`, at most 63 characters (Postgres truncates longer names). */
|
|
21
|
+
export function isPlainIdentifier(value: unknown): value is string {
|
|
22
|
+
return typeof value === 'string' && isIdentifierRange(value, 0, value.length)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A plain identifier, or two joined by one dot (`note_comments.body`, the alias of a joined column). */
|
|
26
|
+
export function isPlainAlias(value: unknown): value is string {
|
|
27
|
+
if (typeof value !== 'string') {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
let dot = -1
|
|
31
|
+
for (let index = 0; index < value.length; index++) {
|
|
32
|
+
if (value[index] === '.') {
|
|
33
|
+
if (dot !== -1) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
dot = index
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (dot === -1) {
|
|
40
|
+
return isIdentifierRange(value, 0, value.length)
|
|
41
|
+
}
|
|
42
|
+
return isIdentifierRange(value, 0, dot) && isIdentifierRange(value, dot + 1, value.length)
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AppCapability,
|
|
3
|
+
AppGrant,
|
|
4
|
+
CapabilityAuthority,
|
|
5
|
+
CapabilityGrant,
|
|
6
|
+
ContainerGrant,
|
|
7
|
+
LeaseFence,
|
|
8
|
+
Ref,
|
|
9
|
+
RefGrant,
|
|
10
|
+
Within,
|
|
11
|
+
WithinGrant,
|
|
12
|
+
} from './capability'
|
|
13
|
+
export { MAX_WITHIN_VALUES } from './capability'
|
|
14
|
+
export { applicationSchema, Database, type PostgresOptions } from './database'
|
|
15
|
+
export type {
|
|
16
|
+
AllowedOperator,
|
|
17
|
+
ComparisonOperator,
|
|
18
|
+
ConditionTuple,
|
|
19
|
+
ListOperator,
|
|
20
|
+
NoColumns,
|
|
21
|
+
NullOperator,
|
|
22
|
+
PatternOperator,
|
|
23
|
+
Primitive,
|
|
24
|
+
ScopedSelect,
|
|
25
|
+
} from './description'
|
|
26
|
+
// `ConflictError` and `NotFoundError` stay internal: an application throws `@fabrika/app`'s, and the framework reads these by name.
|
|
27
|
+
export {
|
|
28
|
+
CAPABILITY_ERRORS,
|
|
29
|
+
CapabilityError,
|
|
30
|
+
type CapabilityErrorReason,
|
|
31
|
+
DatabaseError,
|
|
32
|
+
LeaseLostError,
|
|
33
|
+
PolicyError,
|
|
34
|
+
QUERY_REFUSALS,
|
|
35
|
+
type QueryRefusal,
|
|
36
|
+
QueryRefusedError,
|
|
37
|
+
} from './errors'
|
|
38
|
+
export {
|
|
39
|
+
FILE_STATUSES,
|
|
40
|
+
FILE_TABLE_PREFIX,
|
|
41
|
+
type FileColumn,
|
|
42
|
+
type FileColumnType,
|
|
43
|
+
type FileOwner,
|
|
44
|
+
fileScanSql,
|
|
45
|
+
type FileStatus,
|
|
46
|
+
fileTableName,
|
|
47
|
+
fileTableSql,
|
|
48
|
+
} from './file-table-sql'
|
|
49
|
+
export type {
|
|
50
|
+
AnyDbIndex,
|
|
51
|
+
ChildRows,
|
|
52
|
+
ContainedRows,
|
|
53
|
+
CountOptions,
|
|
54
|
+
DataKind,
|
|
55
|
+
DbIndex,
|
|
56
|
+
IndexedTable,
|
|
57
|
+
ListOptions,
|
|
58
|
+
ListPage,
|
|
59
|
+
NoUnscoped,
|
|
60
|
+
OrderTerm,
|
|
61
|
+
OwnerRow,
|
|
62
|
+
ReadOnlyUnscoped,
|
|
63
|
+
ReferenceRows,
|
|
64
|
+
ReferenceTables,
|
|
65
|
+
RowFor,
|
|
66
|
+
Saved,
|
|
67
|
+
ScopeTarget,
|
|
68
|
+
TargetForm,
|
|
69
|
+
TargetRow,
|
|
70
|
+
Unavailable,
|
|
71
|
+
UnownedTarget,
|
|
72
|
+
UnscopedDeclaration,
|
|
73
|
+
UnscopedInsert,
|
|
74
|
+
UnscopedMode,
|
|
75
|
+
UnscopedRows,
|
|
76
|
+
UnscopedTables,
|
|
77
|
+
VersionArguments,
|
|
78
|
+
WithinRows,
|
|
79
|
+
WithinTables,
|
|
80
|
+
} from './orm-types'
|
|
81
|
+
export {
|
|
82
|
+
type ChildPolicy,
|
|
83
|
+
type ChildScope,
|
|
84
|
+
type ColumnList,
|
|
85
|
+
defineTables,
|
|
86
|
+
type DimensionPolicy,
|
|
87
|
+
type DimensionScope,
|
|
88
|
+
type InboxPolicy,
|
|
89
|
+
type InboxScope,
|
|
90
|
+
type InsertValues,
|
|
91
|
+
isTables,
|
|
92
|
+
readTables,
|
|
93
|
+
type RecordActions,
|
|
94
|
+
type ReferencePolicy,
|
|
95
|
+
type ReferenceScope,
|
|
96
|
+
type RowOf,
|
|
97
|
+
type TableActions,
|
|
98
|
+
type TableInsert,
|
|
99
|
+
Tables,
|
|
100
|
+
type TableUpdate,
|
|
101
|
+
type UpdateValues,
|
|
102
|
+
type VersionColumn,
|
|
103
|
+
type VocabularyTables,
|
|
104
|
+
} from './policy'
|
|
105
|
+
export {
|
|
106
|
+
type AggregateRow,
|
|
107
|
+
type AggregateSpec,
|
|
108
|
+
type ChildListScope,
|
|
109
|
+
type DataLayer,
|
|
110
|
+
type DataLayerRuntime,
|
|
111
|
+
scoped,
|
|
112
|
+
type ScopedDatabase,
|
|
113
|
+
} from './store'
|
|
114
|
+
export { type TablePolicies, tablePolicies } from './tables'
|
|
115
|
+
export type {
|
|
116
|
+
ColumnTypes,
|
|
117
|
+
DefineVocabularyPolicies,
|
|
118
|
+
NormalizedPolicies,
|
|
119
|
+
TableVocabulary,
|
|
120
|
+
TypedColumnList,
|
|
121
|
+
VocabularyAction,
|
|
122
|
+
VocabularyChildScope,
|
|
123
|
+
VocabularyContainer,
|
|
124
|
+
VocabularyContainerOf,
|
|
125
|
+
VocabularyDimension,
|
|
126
|
+
VocabularyDimensionScope,
|
|
127
|
+
VocabularyPolicyMap,
|
|
128
|
+
VocabularyTablePolicy,
|
|
129
|
+
} from './vocabulary-tables'
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// The framework's side of `fabrika_job_leases` (`@fabrika/db/migrations`): the lease a job run takes, the generation
|
|
2
|
+
// and deadline that fence every write of the attempt holding it, and the state of the job's last run; and
|
|
3
|
+
// `fabrika_job_kicks`, a kick's pending run. Only
|
|
4
|
+
// `@fabrika/app`'s job runner reaches it, through `frameworkTablesOf`. Every time is the database's own clock: two
|
|
5
|
+
// containers whose clocks disagree still agree on who holds a lease and when it expires.
|
|
6
|
+
|
|
7
|
+
import type { LeaseFence } from './capability'
|
|
8
|
+
import { runFixedStatement, type Session, sessionOf } from './database'
|
|
9
|
+
import { DatabaseError, LeaseLostError, QueryRefusedError } from './errors'
|
|
10
|
+
import { JOB_KICKS_TABLE, JOB_LEASES_TABLE } from './migrations'
|
|
11
|
+
import type { DataLayerRuntime } from './store'
|
|
12
|
+
|
|
13
|
+
export type JobOutcome = 'succeeded' | 'failed'
|
|
14
|
+
|
|
15
|
+
export interface JobLeaseState {
|
|
16
|
+
readonly generation: number
|
|
17
|
+
/** The attempt holding the lease, or `null` when it is free. */
|
|
18
|
+
readonly holder: string | null
|
|
19
|
+
readonly expiresAt: Date
|
|
20
|
+
/** The holding attempt's deadline: no write of it commits after this. */
|
|
21
|
+
readonly attemptDeadline: Date
|
|
22
|
+
/** When the job was first seen in this schema: the baseline of a job that never succeeded. */
|
|
23
|
+
readonly firstSeenAt: Date
|
|
24
|
+
readonly lastStartedAt: Date | null
|
|
25
|
+
readonly lastFinishedAt: Date | null
|
|
26
|
+
readonly lastStatus: JobOutcome | null
|
|
27
|
+
readonly lastSucceededAt: Date | null
|
|
28
|
+
readonly lastDurationMs: number | null
|
|
29
|
+
readonly lastMissedReportedAt: Date | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A job overdue by twice its period, claimed for one report. */
|
|
33
|
+
export interface OverdueClaim {
|
|
34
|
+
readonly lastSucceededAt: Date | null
|
|
35
|
+
readonly firstSeenAt: Date
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JobLeases {
|
|
39
|
+
/** The database's clock. */
|
|
40
|
+
now(): Promise<Date>
|
|
41
|
+
/** Records that the job exists (its first-seen time), once; later calls change nothing. */
|
|
42
|
+
register(job: string): Promise<void>
|
|
43
|
+
/**
|
|
44
|
+
* Frees a lease whose holder never finished before it expired (the run crashed) and records the run as failed.
|
|
45
|
+
* `true` for the one caller that freed it.
|
|
46
|
+
*/
|
|
47
|
+
reapCrashed(job: string): Promise<boolean>
|
|
48
|
+
/**
|
|
49
|
+
* A free lease for `holder`, under a new generation, for `leaseMs`; its attempt may write for `attemptMs`. `null`
|
|
50
|
+
* while another run holds it.
|
|
51
|
+
*/
|
|
52
|
+
acquire(job: string, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null>
|
|
53
|
+
/**
|
|
54
|
+
* `acquire` of a registered job that never waits for a lock: `null` at once while another run holds the lease or an
|
|
55
|
+
* attempt's transaction holds its row. A kick takes the lease only this way, on a connection requests share.
|
|
56
|
+
*/
|
|
57
|
+
tryAcquire(job: string, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null>
|
|
58
|
+
/**
|
|
59
|
+
* Hands a live lease to the next attempt under a new generation, so no write of the previous attempt commits from
|
|
60
|
+
* now on. `null` when `fence` no longer holds it.
|
|
61
|
+
*/
|
|
62
|
+
renew(fence: LeaseFence, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null>
|
|
63
|
+
/** Frees the lease and records the run's outcome and duration; `false` when `fence` no longer held it. */
|
|
64
|
+
finish(fence: LeaseFence, outcome: JobOutcome): Promise<boolean>
|
|
65
|
+
/** Records that a kick arrived: the run that started before it runs the job once more. Never waits on the lease. */
|
|
66
|
+
markPending(job: string): Promise<void>
|
|
67
|
+
/**
|
|
68
|
+
* Clears the flag `markPending` set; `true` for the one caller that cleared it. A run clears it when it starts (it
|
|
69
|
+
* covers every kick before it) and again after it succeeded, when `true` means one more run.
|
|
70
|
+
*/
|
|
71
|
+
takePending(job: string): Promise<boolean>
|
|
72
|
+
/**
|
|
73
|
+
* Claims one report of a job that has not succeeded within twice `periodMs`, at most once per period: only the
|
|
74
|
+
* caller that gets the claim reports.
|
|
75
|
+
*/
|
|
76
|
+
claimOverdue(job: string, periodMs: number): Promise<OverdueClaim | null>
|
|
77
|
+
/**
|
|
78
|
+
* Refuses every further write of the attempt `fence` names and cancels the statements it still has running; the
|
|
79
|
+
* runner calls it when the attempt ends.
|
|
80
|
+
*/
|
|
81
|
+
cancelInFlight(fence: LeaseFence): Promise<void>
|
|
82
|
+
state(job: string): Promise<JobLeaseState | null>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const T = JOB_LEASES_TABLE
|
|
86
|
+
const MS = `* interval '1 millisecond'`
|
|
87
|
+
const QUERY_CANCELED = '57014'
|
|
88
|
+
const IDLE_TIMEOUT = '25P03'
|
|
89
|
+
|
|
90
|
+
const STATE_COLUMNS =
|
|
91
|
+
'generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at, last_finished_at, last_status, last_succeeded_at, last_duration_ms, last_missed_reported_at'
|
|
92
|
+
|
|
93
|
+
const SQL_NOW = 'select now() as now'
|
|
94
|
+
|
|
95
|
+
const SQL_REGISTER = `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at)
|
|
96
|
+
values ($1, 0, null, now(), now(), now()) on conflict (job) do nothing`
|
|
97
|
+
|
|
98
|
+
const SQL_REAP = `update ${T} set holder = null, last_status = 'failed', last_finished_at = now(),
|
|
99
|
+
last_duration_ms = (extract(epoch from now() - last_started_at) * 1000)::int
|
|
100
|
+
where job = $1 and holder is not null and expires_at <= now() returning job`
|
|
101
|
+
|
|
102
|
+
const SQL_ACQUIRE = `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at)
|
|
103
|
+
values ($1, 1, $2, now() + $3::bigint ${MS}, now() + $4::bigint ${MS}, now(), now())
|
|
104
|
+
on conflict (job) do update set generation = ${T}.generation + 1, holder = excluded.holder, expires_at = excluded.expires_at,
|
|
105
|
+
attempt_deadline = excluded.attempt_deadline, last_started_at = excluded.last_started_at
|
|
106
|
+
where ${T}.holder is null returning generation`
|
|
107
|
+
|
|
108
|
+
const SQL_RENEW = `update ${T} set generation = generation + 1, holder = $4, expires_at = now() + $5::bigint ${MS},
|
|
109
|
+
attempt_deadline = now() + $6::bigint ${MS}
|
|
110
|
+
where job = $1 and generation = $2 and holder = $3 and expires_at > now() returning generation`
|
|
111
|
+
|
|
112
|
+
const SQL_FINISH = `update ${T} set holder = null, last_finished_at = now(), last_status = $4,
|
|
113
|
+
last_duration_ms = (extract(epoch from now() - last_started_at) * 1000)::int,
|
|
114
|
+
last_succeeded_at = case when $4 = 'succeeded' then now() else last_succeeded_at end
|
|
115
|
+
where job = $1 and generation = $2 and holder = $3 returning job`
|
|
116
|
+
|
|
117
|
+
// A free lease whose row no transaction holds (`skip locked`): an attempt's fence holds it `for share` while it writes.
|
|
118
|
+
const SQL_TRY_ACQUIRE = `with free as (select job from ${T} where job = $1 and holder is null for update skip locked)
|
|
119
|
+
update ${T} set generation = ${T}.generation + 1, holder = $2, expires_at = now() + $3::bigint ${MS},
|
|
120
|
+
attempt_deadline = now() + $4::bigint ${MS}, last_started_at = now()
|
|
121
|
+
from free where ${T}.job = free.job returning ${T}.generation`
|
|
122
|
+
|
|
123
|
+
const K = JOB_KICKS_TABLE
|
|
124
|
+
|
|
125
|
+
const SQL_MARK_PENDING = `insert into ${K} (job, pending) values ($1, true) on conflict (job) do update set pending = true where ${K}.pending = false`
|
|
126
|
+
|
|
127
|
+
const SQL_TAKE_PENDING = `update ${K} set pending = false where job = $1 and pending returning job`
|
|
128
|
+
|
|
129
|
+
const SQL_CLAIM_OVERDUE = `update ${T} set last_missed_reported_at = now()
|
|
130
|
+
where job = $1 and coalesce(last_succeeded_at, first_seen_at) < now() - 2 * $2::bigint ${MS}
|
|
131
|
+
and (last_missed_reported_at is null or last_missed_reported_at <= now() - $2::bigint ${MS})
|
|
132
|
+
returning last_succeeded_at, first_seen_at`
|
|
133
|
+
|
|
134
|
+
// Inside a mutation's transaction, so `clock_timestamp()`: `now()` is when the transaction began.
|
|
135
|
+
const SQL_FENCE = `select pg_backend_pid() as pid, transaction_timestamp()::text as began,
|
|
136
|
+
greatest(1, ceil(extract(epoch from attempt_deadline - clock_timestamp()) * 1000))::int as remaining_ms
|
|
137
|
+
from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp() for share`
|
|
138
|
+
|
|
139
|
+
// The attempt's remaining time bounds a statement and an idle wait alike: either past the deadline ends the transaction.
|
|
140
|
+
const SQL_TIMEOUTS = `select set_config('statement_timeout', $1, true), set_config('idle_in_transaction_session_timeout', $1, true)`
|
|
141
|
+
|
|
142
|
+
const SQL_STILL_HELD = `select job from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp()`
|
|
143
|
+
|
|
144
|
+
// Only the backend still in the transaction the fence opened: a pid the pool reused for other work is left alone.
|
|
145
|
+
const SQL_CANCEL = `select pg_cancel_backend(pid) from pg_stat_activity where pid = $1 and xact_start = $2::timestamptz`
|
|
146
|
+
|
|
147
|
+
const SQL_STATE = `select now() as now, ${STATE_COLUMNS} from ${T} where job = $1`
|
|
148
|
+
|
|
149
|
+
function record(row: unknown): Readonly<Record<string, unknown>> {
|
|
150
|
+
if (typeof row !== 'object' || row === null) {
|
|
151
|
+
throw new QueryRefusedError('unexpected_row', `${T} answered a row that is not an object`)
|
|
152
|
+
}
|
|
153
|
+
return Object.fromEntries(Object.entries(row))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function rows(session: Session, text: string, parameters: readonly (string | number | null)[]): Promise<Readonly<Record<string, unknown>>[]> {
|
|
157
|
+
return (await runFixedStatement(session, text, parameters)).map(record)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function integer(row: Readonly<Record<string, unknown>>, name: string): number {
|
|
161
|
+
const value = row[name]
|
|
162
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
163
|
+
throw new QueryRefusedError('unexpected_row', `${T}.${name} came back as ${typeof value}`)
|
|
164
|
+
}
|
|
165
|
+
return value
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function text(row: Readonly<Record<string, unknown>>, name: string): string {
|
|
169
|
+
const value = row[name]
|
|
170
|
+
if (typeof value !== 'string') {
|
|
171
|
+
throw new QueryRefusedError('unexpected_row', `${T}.${name} came back as ${typeof value}`)
|
|
172
|
+
}
|
|
173
|
+
return value
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function date(row: Readonly<Record<string, unknown>>, name: string): Date | null {
|
|
177
|
+
const value = row[name]
|
|
178
|
+
if (value === null) {
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
if (!(value instanceof Date)) {
|
|
182
|
+
throw new QueryRefusedError('unexpected_row', `${T}.${name} came back as ${typeof value}`)
|
|
183
|
+
}
|
|
184
|
+
return value
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function requiredDate(row: Readonly<Record<string, unknown>>, name: string): Date {
|
|
188
|
+
const value = date(row, name)
|
|
189
|
+
if (value === null) {
|
|
190
|
+
throw new QueryRefusedError('unexpected_row', `${T}.${name} came back as null`)
|
|
191
|
+
}
|
|
192
|
+
return value
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stateOf(row: Readonly<Record<string, unknown>>): JobLeaseState {
|
|
196
|
+
const { holder, last_status: status, last_duration_ms: duration } = row
|
|
197
|
+
if ((holder !== null && typeof holder !== 'string') || (duration !== null && typeof duration !== 'number')) {
|
|
198
|
+
throw new QueryRefusedError('unexpected_row', `${T} came back in another shape than its migration declares`)
|
|
199
|
+
}
|
|
200
|
+
if (status !== null && status !== 'succeeded' && status !== 'failed') {
|
|
201
|
+
throw new QueryRefusedError('unexpected_row', `${T}.last_status is not succeeded or failed`)
|
|
202
|
+
}
|
|
203
|
+
return Object.freeze({
|
|
204
|
+
generation: integer(row, 'generation'),
|
|
205
|
+
holder,
|
|
206
|
+
expiresAt: requiredDate(row, 'expires_at'),
|
|
207
|
+
attemptDeadline: requiredDate(row, 'attempt_deadline'),
|
|
208
|
+
firstSeenAt: requiredDate(row, 'first_seen_at'),
|
|
209
|
+
lastStartedAt: date(row, 'last_started_at'),
|
|
210
|
+
lastFinishedAt: date(row, 'last_finished_at'),
|
|
211
|
+
lastStatus: status,
|
|
212
|
+
lastSucceededAt: date(row, 'last_succeeded_at'),
|
|
213
|
+
lastDurationMs: duration,
|
|
214
|
+
lastMissedReportedAt: date(row, 'last_missed_reported_at'),
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function milliseconds(value: number, what: string): number {
|
|
219
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
220
|
+
throw new QueryRefusedError('invalid_argument', `${what} must be a whole number of milliseconds of at least 1`)
|
|
221
|
+
}
|
|
222
|
+
return value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function fenceOf(job: string, holder: string, row: Readonly<Record<string, unknown>> | undefined): LeaseFence | null {
|
|
226
|
+
return row === undefined ? null : Object.freeze({ job, holder, generation: integer(row, 'generation') })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A backend in one fenced transaction: its pid, and when that transaction began. */
|
|
230
|
+
interface Backend {
|
|
231
|
+
readonly pid: number
|
|
232
|
+
readonly began: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Which transactions each attempt has open, how to end them, and which attempts have ended. Keyed by the fence the
|
|
236
|
+
// runner holds.
|
|
237
|
+
const inFlight = new WeakMap<LeaseFence, Set<Backend>>()
|
|
238
|
+
const stops = new WeakMap<LeaseFence, AbortController>()
|
|
239
|
+
const ended = new WeakSet<LeaseFence>()
|
|
240
|
+
|
|
241
|
+
function stopOf(fence: LeaseFence): AbortController {
|
|
242
|
+
const known = stops.get(fence)
|
|
243
|
+
if (known !== undefined) {
|
|
244
|
+
return known
|
|
245
|
+
}
|
|
246
|
+
const stop = new AbortController()
|
|
247
|
+
stops.set(fence, stop)
|
|
248
|
+
return stop
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function refuseEnded(fence: LeaseFence): void {
|
|
252
|
+
if (ended.has(fence)) {
|
|
253
|
+
throw new LeaseLostError(`attempt ${fence.holder} of job '${fence.job}' has ended; it writes nothing more`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const lost = (fence: LeaseFence): LeaseLostError =>
|
|
258
|
+
new LeaseLostError(`job '${fence.job}' lost its lease or its attempt's deadline (generation ${fence.generation}); nothing was written`)
|
|
259
|
+
|
|
260
|
+
const params = (fence: LeaseFence): (string | number)[] => [fence.job, fence.generation, fence.holder]
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Runs `work` in a mutation's transaction under `fence`. Before it: the attempt still holds its lease, the lease row
|
|
264
|
+
* stays locked `for share` until the commit (a takeover or a renewal waits), and every statement is bounded by the
|
|
265
|
+
* attempt's remaining time. After it, just before the commit: the attempt still holds the lease within its deadline.
|
|
266
|
+
* Otherwise the transaction rolls back with `LeaseLostError`; so does a statement the deadline or the runner cancelled.
|
|
267
|
+
*/
|
|
268
|
+
export async function fencedTransaction<T>(session: Session, fence: LeaseFence, work: (transaction: Session) => Promise<T>): Promise<T> {
|
|
269
|
+
refuseEnded(fence)
|
|
270
|
+
let backend: Backend | undefined
|
|
271
|
+
try {
|
|
272
|
+
return await session.transaction(async (transaction) => {
|
|
273
|
+
const [held] = await rows(transaction, SQL_FENCE, params(fence))
|
|
274
|
+
if (held === undefined) {
|
|
275
|
+
throw lost(fence)
|
|
276
|
+
}
|
|
277
|
+
backend = { pid: integer(held, 'pid'), began: text(held, 'began') }
|
|
278
|
+
const running = inFlight.get(fence) ?? new Set<Backend>()
|
|
279
|
+
running.add(backend)
|
|
280
|
+
inFlight.set(fence, running)
|
|
281
|
+
refuseEnded(fence)
|
|
282
|
+
await rows(transaction, SQL_TIMEOUTS, [String(integer(held, 'remaining_ms'))])
|
|
283
|
+
const result = await work(transaction)
|
|
284
|
+
refuseEnded(fence)
|
|
285
|
+
if ((await rows(transaction, SQL_STILL_HELD, params(fence))).length === 0) {
|
|
286
|
+
throw lost(fence)
|
|
287
|
+
}
|
|
288
|
+
return result
|
|
289
|
+
}, stopOf(fence).signal)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (ended.has(fence) || (error instanceof DatabaseError && (error.sqlState === QUERY_CANCELED || error.sqlState === IDLE_TIMEOUT))) {
|
|
292
|
+
throw lost(fence)
|
|
293
|
+
}
|
|
294
|
+
throw error
|
|
295
|
+
} finally {
|
|
296
|
+
if (backend !== undefined) {
|
|
297
|
+
inFlight.get(fence)?.delete(backend)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function jobLeases(runtime: () => DataLayerRuntime): JobLeases {
|
|
303
|
+
const session = () => sessionOf(runtime())
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
async now(): Promise<Date> {
|
|
306
|
+
const [row] = await rows(session(), SQL_NOW, [])
|
|
307
|
+
return requiredDate(row ?? {}, 'now')
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async register(job: string): Promise<void> {
|
|
311
|
+
await rows(session(), SQL_REGISTER, [job])
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async reapCrashed(job: string): Promise<boolean> {
|
|
315
|
+
return (await rows(session(), SQL_REAP, [job])).length > 0
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async acquire(job: string, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null> {
|
|
319
|
+
const [row] = await rows(session(), SQL_ACQUIRE, [job, holder, milliseconds(leaseMs, 'a lease'), milliseconds(attemptMs, 'an attempt')])
|
|
320
|
+
return fenceOf(job, holder, row)
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
async renew(fence: LeaseFence, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null> {
|
|
324
|
+
const [row] = await rows(session(), SQL_RENEW, [
|
|
325
|
+
...params(fence),
|
|
326
|
+
holder,
|
|
327
|
+
milliseconds(leaseMs, 'a lease'),
|
|
328
|
+
milliseconds(attemptMs, 'an attempt'),
|
|
329
|
+
])
|
|
330
|
+
return fenceOf(fence.job, holder, row)
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
async finish(fence: LeaseFence, outcome: JobOutcome): Promise<boolean> {
|
|
334
|
+
return (await rows(session(), SQL_FINISH, [...params(fence), outcome])).length > 0
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async tryAcquire(job: string, holder: string, attemptMs: number, leaseMs: number): Promise<LeaseFence | null> {
|
|
338
|
+
const [row] = await rows(session(), SQL_TRY_ACQUIRE, [job, holder, milliseconds(leaseMs, 'a lease'), milliseconds(attemptMs, 'an attempt')])
|
|
339
|
+
return fenceOf(job, holder, row)
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async markPending(job: string): Promise<void> {
|
|
343
|
+
await rows(session(), SQL_MARK_PENDING, [job])
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async takePending(job: string): Promise<boolean> {
|
|
347
|
+
return (await rows(session(), SQL_TAKE_PENDING, [job])).length > 0
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async claimOverdue(job: string, periodMs: number): Promise<OverdueClaim | null> {
|
|
351
|
+
const [row] = await rows(session(), SQL_CLAIM_OVERDUE, [job, milliseconds(periodMs, 'a period')])
|
|
352
|
+
return row === undefined
|
|
353
|
+
? null
|
|
354
|
+
: Object.freeze({ lastSucceededAt: date(row, 'last_succeeded_at'), firstSeenAt: requiredDate(row, 'first_seen_at') })
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async cancelInFlight(fence: LeaseFence): Promise<void> {
|
|
358
|
+
ended.add(fence)
|
|
359
|
+
// The transaction rolls back and gives its connection back now, even while its work waits on something else;
|
|
360
|
+
// a statement still running is cancelled, so the ROLLBACK queued behind it runs.
|
|
361
|
+
stopOf(fence).abort(lost(fence))
|
|
362
|
+
const running = [...(inFlight.get(fence) ?? [])]
|
|
363
|
+
for (const backend of running) {
|
|
364
|
+
await rows(session(), SQL_CANCEL, [backend.pid, backend.began])
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
async state(job: string): Promise<JobLeaseState | null> {
|
|
369
|
+
const [row] = await rows(session(), SQL_STATE, [job])
|
|
370
|
+
return row === undefined ? null : stateOf(row)
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A Ref's predicate over a table's containment links: the first link takes the Ref's id, every other link a value
|
|
2
|
+
// of its chain, and a chain entry no link covers is refused. Shared by the stores and the file collections' tables.
|
|
3
|
+
|
|
4
|
+
import type { OperationNode } from 'kysely'
|
|
5
|
+
import { freeze, inSet, nameSet, ownEntry, ownKeys, recordOf } from './builtins'
|
|
6
|
+
import type { RefGrant } from './capability'
|
|
7
|
+
import { CapabilityError } from './errors'
|
|
8
|
+
import { allOf, equals } from './nodes'
|
|
9
|
+
import type { Container } from './policy'
|
|
10
|
+
|
|
11
|
+
function chainValue(grant: RefGrant, chain: Readonly<Record<string, string>>, dimension: string): string {
|
|
12
|
+
const value = ownEntry(chain, dimension)
|
|
13
|
+
if (value === undefined) {
|
|
14
|
+
throw new CapabilityError('out_of_reach', `the Ref of ${grant.dimension} carries no ${dimension} in its chain`)
|
|
15
|
+
}
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LinkValue {
|
|
20
|
+
readonly link: Container
|
|
21
|
+
readonly value: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The column values a Ref addresses over `links`: the first link takes the Ref's id, every other link a value
|
|
26
|
+
* from its chain. A chain entry no link covers cannot be enforced, so it is refused rather than ignored.
|
|
27
|
+
*/
|
|
28
|
+
export function linkValues(table: string, links: readonly Container[], grant: RefGrant): readonly LinkValue[] {
|
|
29
|
+
const first = links[0]
|
|
30
|
+
if (first === undefined) {
|
|
31
|
+
throw new CapabilityError('out_of_reach', `${table} has no containment link for a Ref of ${grant.dimension}`)
|
|
32
|
+
}
|
|
33
|
+
const chain = recordOf(grant.chain)
|
|
34
|
+
const covered: string[] = []
|
|
35
|
+
for (let index = 1; index < links.length; index++) {
|
|
36
|
+
covered[covered.length] = links[index]?.dimension ?? ''
|
|
37
|
+
}
|
|
38
|
+
const coveredSet = nameSet(covered)
|
|
39
|
+
const dimensions = ownKeys(chain)
|
|
40
|
+
for (let index = 0; index < dimensions.length; index++) {
|
|
41
|
+
const dimension = dimensions[index] ?? ''
|
|
42
|
+
if (!inSet(coveredSet, dimension)) {
|
|
43
|
+
throw new CapabilityError(
|
|
44
|
+
'unenforceable',
|
|
45
|
+
`the Ref of ${grant.dimension} lies in ${dimension}, which ${table} carries no column for; the layer cannot enforce it`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const values: LinkValue[] = [{ link: first, value: grant.id }]
|
|
50
|
+
for (let index = 1; index < links.length; index++) {
|
|
51
|
+
const link = links[index]
|
|
52
|
+
if (link !== undefined) {
|
|
53
|
+
values[values.length] = { link, value: chainValue(grant, chain, link.dimension) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return freeze(values)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function linksPredicate(table: string, values: readonly LinkValue[]): OperationNode {
|
|
60
|
+
return allOf(values.map(({ link, value }) => equals(table, link.column, value)))
|
|
61
|
+
}
|