@vrplatform/kysely 1.3.45-stage.669 → 1.3.45-stage.710
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/build/main/v1.generated.d.ts +2 -0
- package/build/main/v1.generated.js.map +1 -1
- package/build/migrations-v2/0000000000056-journal-operation-failure-precedence.ts +306 -0
- package/build/migrations-v2/0000000000057-journal-invalidation-failure-code.ts +28 -0
- package/build/module/v1.generated.d.ts +2 -0
- package/build/module/v1.generated.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { type Kysely, sql } from 'kysely';
|
|
2
|
+
|
|
3
|
+
// The served `core.operation.failure_code` projection must agree with the
|
|
4
|
+
// TypeScript classifier in `packages/repository/src/journal-freshness/load.ts`:
|
|
5
|
+
// a governing accounting lock wins over a retained failure (CUS-2936), and a
|
|
6
|
+
// retained unbalanced attempt is recognized by its message prefix as well as by
|
|
7
|
+
// the typed result code (CUS-2894).
|
|
8
|
+
const failureProjection = (ordering: 'lock-first' | 'failure-first') => sql`
|
|
9
|
+
create or replace function core.refresh_operation_state(
|
|
10
|
+
target_operation_id uuid
|
|
11
|
+
) returns void
|
|
12
|
+
language plpgsql
|
|
13
|
+
as $$
|
|
14
|
+
declare
|
|
15
|
+
next_status core.operation_status;
|
|
16
|
+
next_started_at timestamptz;
|
|
17
|
+
next_finished_at timestamptz;
|
|
18
|
+
next_failure_code text;
|
|
19
|
+
next_failure_message text;
|
|
20
|
+
target_operation_type core.operation_type;
|
|
21
|
+
target_public_id_format core.operation_public_id_format;
|
|
22
|
+
target_action_id uuid;
|
|
23
|
+
begin
|
|
24
|
+
select
|
|
25
|
+
operation.type,
|
|
26
|
+
operation.public_id_format,
|
|
27
|
+
source.source_id
|
|
28
|
+
into
|
|
29
|
+
target_operation_type,
|
|
30
|
+
target_public_id_format,
|
|
31
|
+
target_action_id
|
|
32
|
+
from core.operation as operation
|
|
33
|
+
left join core.operation_source as source
|
|
34
|
+
on source.operation_id = operation.id
|
|
35
|
+
and source.source_type = 'audit-action'
|
|
36
|
+
where operation.id = target_operation_id;
|
|
37
|
+
|
|
38
|
+
select
|
|
39
|
+
case
|
|
40
|
+
when count(*) filter (where required) = 0 then 'queued'
|
|
41
|
+
when bool_and(status = 'completed') filter (where required)
|
|
42
|
+
then 'completed'
|
|
43
|
+
when bool_and(status in ('completed', 'failed')) filter (where required)
|
|
44
|
+
then 'failed'
|
|
45
|
+
when bool_or(status = 'running') filter (where required)
|
|
46
|
+
or bool_or(status in ('completed', 'failed')) filter (where required)
|
|
47
|
+
then 'running'
|
|
48
|
+
else 'queued'
|
|
49
|
+
end::core.operation_status,
|
|
50
|
+
min(started_at) filter (where required),
|
|
51
|
+
max(finished_at) filter (where required)
|
|
52
|
+
into next_status, next_started_at, next_finished_at
|
|
53
|
+
from core.operation_source
|
|
54
|
+
where operation_id = target_operation_id;
|
|
55
|
+
|
|
56
|
+
if next_status = 'failed' then
|
|
57
|
+
if target_operation_type = 'reservation-journal-refresh'
|
|
58
|
+
and target_action_id is not null then
|
|
59
|
+
select
|
|
60
|
+
case
|
|
61
|
+
${sql.raw(
|
|
62
|
+
ordering === 'lock-first'
|
|
63
|
+
? `when core.journal_refresh_all_outstanding_locked(
|
|
64
|
+
bool_and(
|
|
65
|
+
case
|
|
66
|
+
when invalidation.id is not null
|
|
67
|
+
and invalidation.applied_at is null
|
|
68
|
+
then effect.status = 'completed'
|
|
69
|
+
and attempt.status is not distinct from 'skipped_locked'
|
|
70
|
+
end
|
|
71
|
+
),
|
|
72
|
+
bool_and(
|
|
73
|
+
case
|
|
74
|
+
when invalidation.id is not null
|
|
75
|
+
and invalidation.applied_at is null
|
|
76
|
+
then core.journal_reservation_refresh_locked(
|
|
77
|
+
invalidation.tenant_id,
|
|
78
|
+
invalidation.reservation_id
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
)
|
|
82
|
+
) then 'journalLocked'
|
|
83
|
+
when bool_or(
|
|
84
|
+
invalidation.applied_at is null
|
|
85
|
+
and attempt.result_code = 'journal_configuration_invalid'
|
|
86
|
+
) then 'journalConfigurationInvalid'
|
|
87
|
+
when bool_or(
|
|
88
|
+
invalidation.applied_at is null
|
|
89
|
+
and (
|
|
90
|
+
attempt.result_code = 'journal_unbalanced'
|
|
91
|
+
or attempt.error_message like 'Journal refresh produced unbalanced%'
|
|
92
|
+
)
|
|
93
|
+
) then 'journalUnbalanced'`
|
|
94
|
+
: `when bool_or(
|
|
95
|
+
invalidation.applied_at is null
|
|
96
|
+
and attempt.result_code = 'journal_unbalanced'
|
|
97
|
+
) then 'journalUnbalanced'
|
|
98
|
+
when bool_or(
|
|
99
|
+
invalidation.applied_at is null
|
|
100
|
+
and attempt.result_code = 'journal_configuration_invalid'
|
|
101
|
+
) then 'journalConfigurationInvalid'
|
|
102
|
+
when bool_or(
|
|
103
|
+
invalidation.applied_at is null
|
|
104
|
+
and attempt.result_code in (
|
|
105
|
+
'statement_period_locked',
|
|
106
|
+
'skipped_locked'
|
|
107
|
+
)
|
|
108
|
+
) then 'journalLocked'`
|
|
109
|
+
)}
|
|
110
|
+
else 'journalRefreshFailed'
|
|
111
|
+
end,
|
|
112
|
+
case
|
|
113
|
+
${sql.raw(
|
|
114
|
+
ordering === 'lock-first'
|
|
115
|
+
? `when core.journal_refresh_all_outstanding_locked(
|
|
116
|
+
bool_and(
|
|
117
|
+
case
|
|
118
|
+
when invalidation.id is not null
|
|
119
|
+
and invalidation.applied_at is null
|
|
120
|
+
then effect.status = 'completed'
|
|
121
|
+
and attempt.status is not distinct from 'skipped_locked'
|
|
122
|
+
end
|
|
123
|
+
),
|
|
124
|
+
bool_and(
|
|
125
|
+
case
|
|
126
|
+
when invalidation.id is not null
|
|
127
|
+
and invalidation.applied_at is null
|
|
128
|
+
then core.journal_reservation_refresh_locked(
|
|
129
|
+
invalidation.tenant_id,
|
|
130
|
+
invalidation.reservation_id
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
)
|
|
134
|
+
) then 'Journal refresh is blocked by accounting locks'
|
|
135
|
+
when bool_or(
|
|
136
|
+
invalidation.applied_at is null
|
|
137
|
+
and attempt.result_code = 'journal_configuration_invalid'
|
|
138
|
+
) then 'Journal configuration is invalid'
|
|
139
|
+
when bool_or(
|
|
140
|
+
invalidation.applied_at is null
|
|
141
|
+
and (
|
|
142
|
+
attempt.result_code = 'journal_unbalanced'
|
|
143
|
+
or attempt.error_message like 'Journal refresh produced unbalanced%'
|
|
144
|
+
)
|
|
145
|
+
) then 'Journal refresh produced unbalanced entries'`
|
|
146
|
+
: `when bool_or(
|
|
147
|
+
invalidation.applied_at is null
|
|
148
|
+
and attempt.result_code = 'journal_unbalanced'
|
|
149
|
+
) then 'Journal refresh produced unbalanced entries'
|
|
150
|
+
when bool_or(
|
|
151
|
+
invalidation.applied_at is null
|
|
152
|
+
and attempt.result_code = 'journal_configuration_invalid'
|
|
153
|
+
) then 'Journal configuration is invalid'
|
|
154
|
+
when bool_or(
|
|
155
|
+
invalidation.applied_at is null
|
|
156
|
+
and attempt.result_code in (
|
|
157
|
+
'statement_period_locked',
|
|
158
|
+
'skipped_locked'
|
|
159
|
+
)
|
|
160
|
+
) then 'Journal refresh is blocked by accounting locks'`
|
|
161
|
+
)}
|
|
162
|
+
else 'Journal refresh failed'
|
|
163
|
+
end
|
|
164
|
+
into next_failure_code, next_failure_message
|
|
165
|
+
from audit.effect as effect
|
|
166
|
+
left join accounting.reservation_journal_invalidation as invalidation
|
|
167
|
+
on invalidation.tenant_id = effect.tenant_id
|
|
168
|
+
and invalidation.effect_id = effect.id
|
|
169
|
+
left join lateral (
|
|
170
|
+
select
|
|
171
|
+
effect_attempt.status,
|
|
172
|
+
effect_attempt.result_code,
|
|
173
|
+
effect_attempt.error_message
|
|
174
|
+
from audit.effect_attempt as effect_attempt
|
|
175
|
+
where effect_attempt.tenant_id = effect.tenant_id
|
|
176
|
+
and effect_attempt.effect_id = effect.id
|
|
177
|
+
order by effect_attempt.attempt_num desc
|
|
178
|
+
limit 1
|
|
179
|
+
) as attempt on true
|
|
180
|
+
where effect.action_id = target_action_id
|
|
181
|
+
and effect.effect_type = 'REFRESH_RESERVATION_JOURNAL'
|
|
182
|
+
and effect.entity_type = 'reservation';
|
|
183
|
+
else
|
|
184
|
+
next_failure_code := 'operation_failed';
|
|
185
|
+
next_failure_message := 'Operation failed';
|
|
186
|
+
end if;
|
|
187
|
+
else
|
|
188
|
+
next_failure_code := null;
|
|
189
|
+
next_failure_message := null;
|
|
190
|
+
end if;
|
|
191
|
+
|
|
192
|
+
update core.operation
|
|
193
|
+
set
|
|
194
|
+
status = next_status,
|
|
195
|
+
started_at = coalesce(core.operation.started_at, next_started_at),
|
|
196
|
+
finished_at = case
|
|
197
|
+
when next_status in ('completed', 'failed')
|
|
198
|
+
then coalesce(next_finished_at, now())
|
|
199
|
+
else null
|
|
200
|
+
end,
|
|
201
|
+
failure_code = next_failure_code,
|
|
202
|
+
failure_message = next_failure_message,
|
|
203
|
+
updated_at = now(),
|
|
204
|
+
source_version = source_version + 1
|
|
205
|
+
where id = target_operation_id
|
|
206
|
+
and (
|
|
207
|
+
status is distinct from next_status
|
|
208
|
+
or started_at is distinct from coalesce(started_at, next_started_at)
|
|
209
|
+
or finished_at is distinct from case
|
|
210
|
+
when next_status in ('completed', 'failed')
|
|
211
|
+
then coalesce(next_finished_at, finished_at, now())
|
|
212
|
+
else null
|
|
213
|
+
end
|
|
214
|
+
or failure_code is distinct from next_failure_code
|
|
215
|
+
or failure_message is distinct from next_failure_message
|
|
216
|
+
);
|
|
217
|
+
end;
|
|
218
|
+
$$
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
// The reservation lock driver from `isReservationRefreshLocked`: the refresh is
|
|
222
|
+
// locked when every active reservation entry sits in a published statement or
|
|
223
|
+
// in closed books.
|
|
224
|
+
const reservationRefreshLocked = sql`
|
|
225
|
+
create or replace function core.journal_reservation_refresh_locked(
|
|
226
|
+
target_tenant_id uuid,
|
|
227
|
+
target_reservation_id uuid
|
|
228
|
+
) returns boolean
|
|
229
|
+
language sql
|
|
230
|
+
stable
|
|
231
|
+
as $$
|
|
232
|
+
select exists (
|
|
233
|
+
select 1
|
|
234
|
+
from accounting.journal_entry as entry
|
|
235
|
+
inner join public.tenant as tenant on tenant.id = entry.tenant_id
|
|
236
|
+
where entry.tenant_id = target_tenant_id
|
|
237
|
+
and entry.reservation_id = target_reservation_id
|
|
238
|
+
and entry.status = 'active'
|
|
239
|
+
and (entry.entity_type is null or entry.entity_type = 'reservation')
|
|
240
|
+
and (
|
|
241
|
+
entry.attached_to_owner_statement_id is not null
|
|
242
|
+
or entry.transfer_owner_statement_id is not null
|
|
243
|
+
or entry.txn_at < tenant.books_closed_at
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
$$
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
// `bool_and` over no outstanding invalidation returns null; the classifier
|
|
250
|
+
// treats that as "not locked".
|
|
251
|
+
const allOutstandingLocked = sql`
|
|
252
|
+
create or replace function core.journal_refresh_all_outstanding_locked(
|
|
253
|
+
all_lock_skipped boolean,
|
|
254
|
+
all_reservations_locked boolean
|
|
255
|
+
) returns boolean
|
|
256
|
+
language sql
|
|
257
|
+
immutable
|
|
258
|
+
as $$
|
|
259
|
+
select coalesce(all_lock_skipped, false) or coalesce(all_reservations_locked, false)
|
|
260
|
+
$$
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
async function recomputeFailedJournalOperations(db: Kysely<unknown>) {
|
|
264
|
+
// Derived operation state only. Attempts, invalidations and journals are untouched.
|
|
265
|
+
await sql`
|
|
266
|
+
select core.refresh_operation_state(operation.id)
|
|
267
|
+
from core.operation as operation
|
|
268
|
+
where operation.type = 'reservation-journal-refresh'
|
|
269
|
+
and operation.status = 'failed'
|
|
270
|
+
and exists (
|
|
271
|
+
select 1 from core.operation_source as source
|
|
272
|
+
where source.operation_id = operation.id
|
|
273
|
+
and source.source_type = 'audit-action'
|
|
274
|
+
)
|
|
275
|
+
`.execute(db);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
279
|
+
await sql`
|
|
280
|
+
do $$
|
|
281
|
+
begin
|
|
282
|
+
if position(
|
|
283
|
+
'and target_public_id_format = ''action'''
|
|
284
|
+
in pg_get_functiondef('core.refresh_operation_state(uuid)'::regprocedure)
|
|
285
|
+
) > 0 then
|
|
286
|
+
raise exception 'Journal operation failure projection has an unexpected definition';
|
|
287
|
+
end if;
|
|
288
|
+
end;
|
|
289
|
+
$$
|
|
290
|
+
`.execute(db);
|
|
291
|
+
await reservationRefreshLocked.execute(db);
|
|
292
|
+
await allOutstandingLocked.execute(db);
|
|
293
|
+
await failureProjection('lock-first').execute(db);
|
|
294
|
+
await recomputeFailedJournalOperations(db);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
298
|
+
await failureProjection('failure-first').execute(db);
|
|
299
|
+
await recomputeFailedJournalOperations(db);
|
|
300
|
+
await sql`drop function if exists core.journal_refresh_all_outstanding_locked(boolean, boolean)`.execute(
|
|
301
|
+
db
|
|
302
|
+
);
|
|
303
|
+
await sql`drop function if exists core.journal_reservation_refresh_locked(uuid, uuid)`.execute(
|
|
304
|
+
db
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
|
|
4
|
+
// An inline reservation journal refresh has no effect row, so a refresh that
|
|
5
|
+
// completes without writing the reservation's postings had nowhere to record
|
|
6
|
+
// why. Storage only: nullable column, no default, no backfill, no rewrite.
|
|
7
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
8
|
+
await sql`
|
|
9
|
+
create type accounting.reservation_journal_invalidation_failure as enum (
|
|
10
|
+
'journal_configuration_invalid'
|
|
11
|
+
)
|
|
12
|
+
`.execute(db);
|
|
13
|
+
await sql`
|
|
14
|
+
alter table accounting.reservation_journal_invalidation
|
|
15
|
+
add column failure_code
|
|
16
|
+
accounting.reservation_journal_invalidation_failure
|
|
17
|
+
`.execute(db);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
21
|
+
await sql`
|
|
22
|
+
alter table accounting.reservation_journal_invalidation
|
|
23
|
+
drop column if exists failure_code
|
|
24
|
+
`.execute(db);
|
|
25
|
+
await sql`
|
|
26
|
+
drop type if exists accounting.reservation_journal_invalidation_failure
|
|
27
|
+
`.execute(db);
|
|
28
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ColumnType } from "kysely";
|
|
6
6
|
export type AccountingJournalEntryInactiveReason = "cancelledReservationLine" | "currencyMismatch" | "generalLedgerStartAt" | "historicalLedger" | "inactiveAccount" | "listingInactive" | "listingOwnershipPeriodNotFound" | "nonPostingAccount" | "reservationGeneralLedgerStatusInactive" | "transactionInactive" | "unbalancedGroupInactiveLeg" | "unknownInactiveReason";
|
|
7
7
|
export type AccountingJournalEntryLedger = "historical" | "operating" | "trust";
|
|
8
|
+
export type AccountingReservationJournalInvalidationFailure = "journal_configuration_invalid";
|
|
8
9
|
export type AccountingTransactionAttachmentStatus = "attached" | "deleting" | "ready" | "uploading";
|
|
9
10
|
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
|
10
11
|
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
|
|
@@ -418,6 +419,7 @@ export interface AccountingReservationJournalInvalidation {
|
|
|
418
419
|
coveredByEffectId: string | null;
|
|
419
420
|
createdAt: Generated<Timestamp>;
|
|
420
421
|
effectId: string | null;
|
|
422
|
+
failureCode: AccountingReservationJournalInvalidationFailure | null;
|
|
421
423
|
id: Generated<string>;
|
|
422
424
|
reservationId: string;
|
|
423
425
|
revision: Int8;
|