@devflow-tools/database 0.16.18 → 0.16.20
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/CHANGELOG.md +27 -0
- package/__tests__/database.host-actions.test.ts +405 -0
- package/__tests__/database.retrieval-sessions.test.ts +79 -0
- package/dist/database.d.ts +31 -0
- package/dist/database.js +481 -0
- package/dist/host-actions.d.ts +47 -0
- package/dist/host-actions.js +152 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -1
- package/dist/retrieval-sessions.d.ts +74 -0
- package/dist/retrieval-sessions.js +117 -0
- package/package.json +1 -1
- package/src/database.ts +589 -0
- package/src/host-actions.ts +211 -0
- package/src/index.ts +25 -0
- package/src/retrieval-sessions.ts +196 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export type HostActionState =
|
|
2
|
+
| 'waiting'
|
|
3
|
+
| 'running'
|
|
4
|
+
| 'reported'
|
|
5
|
+
| 'verified'
|
|
6
|
+
| 'failed'
|
|
7
|
+
| 'cancelled'
|
|
8
|
+
| 'degraded';
|
|
9
|
+
|
|
10
|
+
export interface HostActionRecord {
|
|
11
|
+
actionId: string;
|
|
12
|
+
runId: string;
|
|
13
|
+
engineRunId: string;
|
|
14
|
+
stepId: string;
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
executionId?: string;
|
|
18
|
+
contextReceipt?: string;
|
|
19
|
+
state: HostActionState;
|
|
20
|
+
report: Record<string, unknown>;
|
|
21
|
+
evidenceHash?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
finishedAt?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RequestHostActionInput {
|
|
28
|
+
actionId?: string;
|
|
29
|
+
runId: string;
|
|
30
|
+
engineRunId: string;
|
|
31
|
+
stepId: string;
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
sessionId?: string;
|
|
34
|
+
executionId?: string;
|
|
35
|
+
contextReceipt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartHostActionInput {
|
|
39
|
+
actionId: string;
|
|
40
|
+
projectRoot: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReportHostActionInput {
|
|
44
|
+
actionId: string;
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
report: Record<string, unknown>;
|
|
47
|
+
evidenceHash?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VerifyHostActionInput extends ReportHostActionInput {
|
|
51
|
+
evidenceHash: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FailHostActionInput extends ReportHostActionInput {
|
|
55
|
+
outcome?: 'failed' | 'cancelled' | 'degraded';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HOST_ACTION_STATES = new Set<HostActionState>([
|
|
59
|
+
'waiting',
|
|
60
|
+
'running',
|
|
61
|
+
'reported',
|
|
62
|
+
'verified',
|
|
63
|
+
'failed',
|
|
64
|
+
'cancelled',
|
|
65
|
+
'degraded',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
export function isHostActionState(value: unknown): value is HostActionState {
|
|
69
|
+
return typeof value === 'string' && HOST_ACTION_STATES.has(value as HostActionState);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapHostActionRow(row: Record<string, unknown>): HostActionRecord {
|
|
73
|
+
if (!isHostActionState(row.state)) {
|
|
74
|
+
throw new Error(`Invalid persisted host action state: ${String(row.state)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
actionId: asRequiredString(row.action_id, 'action_id'),
|
|
79
|
+
runId: asRequiredString(row.run_id, 'run_id'),
|
|
80
|
+
engineRunId: asRequiredString(row.engine_run_id, 'engine_run_id'),
|
|
81
|
+
stepId: asRequiredString(row.step_id, 'step_id'),
|
|
82
|
+
projectRoot: asRequiredString(row.project_root, 'project_root'),
|
|
83
|
+
sessionId: asOptionalString(row.session_id),
|
|
84
|
+
executionId: asOptionalString(row.execution_id),
|
|
85
|
+
contextReceipt: asOptionalString(row.context_receipt),
|
|
86
|
+
state: row.state,
|
|
87
|
+
report: parseReport(row.report_json),
|
|
88
|
+
evidenceHash: asOptionalString(row.evidence_hash),
|
|
89
|
+
createdAt: asFiniteNumber(row.created_at, 'created_at'),
|
|
90
|
+
updatedAt: asFiniteNumber(row.updated_at, 'updated_at'),
|
|
91
|
+
finishedAt: asOptionalFiniteNumber(row.finished_at),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function serializeHostActionReport(report: Record<string, unknown>): string {
|
|
96
|
+
if (!isPlainObject(report)) throw new Error('Host action report must be a plain object');
|
|
97
|
+
return JSON.stringify(canonicalizeJson(report, '$', new WeakSet<object>()));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function hostActionReportsEqual(
|
|
101
|
+
left: Record<string, unknown>,
|
|
102
|
+
right: Record<string, unknown>,
|
|
103
|
+
): boolean {
|
|
104
|
+
return serializeHostActionReport(left) === serializeHostActionReport(right);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseReport(value: unknown): Record<string, unknown> {
|
|
108
|
+
if (typeof value !== 'string') {
|
|
109
|
+
throw new Error('Invalid persisted host action report_json: expected JSON text');
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(value);
|
|
113
|
+
if (!isPlainObject(parsed)) {
|
|
114
|
+
throw new Error('expected a JSON object');
|
|
115
|
+
}
|
|
116
|
+
serializeHostActionReport(parsed);
|
|
117
|
+
return parsed;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
120
|
+
throw new Error(`Invalid persisted host action report_json: ${detail}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function canonicalizeJson(value: unknown, path: string, ancestors: WeakSet<object>): unknown {
|
|
125
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
126
|
+
if (typeof value === 'number') {
|
|
127
|
+
if (!Number.isFinite(value) || Object.is(value, -0)) {
|
|
128
|
+
throw nonJsonValueError(path);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value !== 'object') throw nonJsonValueError(path);
|
|
133
|
+
if (ancestors.has(value)) throw new Error(`Host action report contains a cycle at ${path}`);
|
|
134
|
+
|
|
135
|
+
ancestors.add(value);
|
|
136
|
+
try {
|
|
137
|
+
if (Array.isArray(value)) return canonicalizeArray(value, path, ancestors);
|
|
138
|
+
if (!isPlainObject(value)) throw nonJsonValueError(path);
|
|
139
|
+
|
|
140
|
+
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
141
|
+
const keys = Reflect.ownKeys(value);
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
if (typeof key !== 'string') throw nonJsonValueError(`${path}[symbol]`);
|
|
144
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
145
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
146
|
+
throw nonJsonValueError(`${path}.${key}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const key of (keys as string[]).slice().sort(comparePropertyKeys)) {
|
|
150
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
151
|
+
result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
} finally {
|
|
155
|
+
ancestors.delete(value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function canonicalizeArray(value: unknown[], path: string, ancestors: WeakSet<object>): unknown[] {
|
|
160
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) throw nonJsonValueError(path);
|
|
161
|
+
const keys = Reflect.ownKeys(value);
|
|
162
|
+
const expectedKeys = new Set(['length', ...Array.from({ length: value.length }, (_, index) => String(index))]);
|
|
163
|
+
if (keys.some(key => typeof key !== 'string' || !expectedKeys.has(key))) {
|
|
164
|
+
throw nonJsonValueError(path);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
168
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
169
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
170
|
+
throw nonJsonValueError(`${path}[${index}]`);
|
|
171
|
+
}
|
|
172
|
+
return canonicalizeJson(descriptor.value, `${path}[${index}]`, ancestors);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function comparePropertyKeys(left: string, right: string): number {
|
|
177
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nonJsonValueError(path: string): Error {
|
|
181
|
+
return new Error(`Host action report contains a non-JSON value at ${path}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
185
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
186
|
+
const prototype = Object.getPrototypeOf(value);
|
|
187
|
+
return prototype === Object.prototype || prototype === null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asRequiredString(value: unknown, column: string): string {
|
|
191
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
192
|
+
throw new Error(`Invalid persisted host action ${column}`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
198
|
+
return typeof value === 'string' ? value : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function asFiniteNumber(value: unknown, column: string): number {
|
|
202
|
+
const number = Number(value);
|
|
203
|
+
if (!Number.isFinite(number)) throw new Error(`Invalid persisted host action ${column}`);
|
|
204
|
+
return number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function asOptionalFiniteNumber(value: unknown): number | undefined {
|
|
208
|
+
if (value === null || value === undefined) return undefined;
|
|
209
|
+
const number = Number(value);
|
|
210
|
+
return Number.isFinite(number) ? number : undefined;
|
|
211
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,31 @@ export type {
|
|
|
30
30
|
WorkQueueHealth,
|
|
31
31
|
WorkState,
|
|
32
32
|
} from './work-queue';
|
|
33
|
+
export {
|
|
34
|
+
hostActionReportsEqual,
|
|
35
|
+
isHostActionState,
|
|
36
|
+
mapHostActionRow,
|
|
37
|
+
serializeHostActionReport,
|
|
38
|
+
} from './host-actions';
|
|
39
|
+
export { RETRIEVAL_MAX_CYCLES, isRetrievalSessionState } from './retrieval-sessions';
|
|
40
|
+
export type {
|
|
41
|
+
AppendRetrievalCycleInput,
|
|
42
|
+
CreateRetrievalSessionInput,
|
|
43
|
+
RetrievalCycleRecord,
|
|
44
|
+
RetrievalGapKind,
|
|
45
|
+
RetrievalGapRecord,
|
|
46
|
+
RetrievalSessionRecord,
|
|
47
|
+
RetrievalSessionState,
|
|
48
|
+
} from './retrieval-sessions';
|
|
49
|
+
export type {
|
|
50
|
+
FailHostActionInput,
|
|
51
|
+
HostActionRecord,
|
|
52
|
+
HostActionState,
|
|
53
|
+
ReportHostActionInput,
|
|
54
|
+
RequestHostActionInput,
|
|
55
|
+
StartHostActionInput,
|
|
56
|
+
VerifyHostActionInput,
|
|
57
|
+
} from './host-actions';
|
|
33
58
|
export {
|
|
34
59
|
mapSessionObligationRow,
|
|
35
60
|
normalizeTurnId,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
export const RETRIEVAL_MAX_CYCLES = 3 as const;
|
|
2
|
+
|
|
3
|
+
export type RetrievalSessionState = 'open' | 'satisfied' | 'exhausted' | 'expired';
|
|
4
|
+
|
|
5
|
+
export type RetrievalGapKind =
|
|
6
|
+
| 'target'
|
|
7
|
+
| 'caller'
|
|
8
|
+
| 'route'
|
|
9
|
+
| 'resource'
|
|
10
|
+
| 'test'
|
|
11
|
+
| 'configuration'
|
|
12
|
+
| 'symbol_ambiguity'
|
|
13
|
+
| 'memory_applicability'
|
|
14
|
+
| 'knowledge_version'
|
|
15
|
+
| 'runtime_evidence'
|
|
16
|
+
| 'unavailable_channel';
|
|
17
|
+
|
|
18
|
+
export interface RetrievalGapRecord {
|
|
19
|
+
kind: RetrievalGapKind;
|
|
20
|
+
target?: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
evidence: string[];
|
|
23
|
+
resolver: 'codegraph' | 'memory' | 'knowledge' | 'analyzer' | 'none';
|
|
24
|
+
priority: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RetrievalCycleRecord {
|
|
28
|
+
retrievalSessionId: string;
|
|
29
|
+
cycle: number;
|
|
30
|
+
gaps: RetrievalGapRecord[];
|
|
31
|
+
selectedIds: string[];
|
|
32
|
+
rejectedIds: string[];
|
|
33
|
+
tokenCost: number;
|
|
34
|
+
remainingTokenBudget: number;
|
|
35
|
+
quality: Record<string, unknown>;
|
|
36
|
+
receipt: string;
|
|
37
|
+
evidenceHash: string;
|
|
38
|
+
createdAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RetrievalSessionRecord {
|
|
42
|
+
id: string;
|
|
43
|
+
requestId: string;
|
|
44
|
+
projectRoot: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
executionId?: string;
|
|
47
|
+
query: string;
|
|
48
|
+
intent: string;
|
|
49
|
+
state: RetrievalSessionState;
|
|
50
|
+
cycle: number;
|
|
51
|
+
maxCycles: typeof RETRIEVAL_MAX_CYCLES;
|
|
52
|
+
initialTokenBudget: number;
|
|
53
|
+
remainingTokenBudget: number;
|
|
54
|
+
baselineReceipt: string;
|
|
55
|
+
finalReceipt?: string;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
expiresAt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateRetrievalSessionInput {
|
|
62
|
+
id?: string;
|
|
63
|
+
requestId: string;
|
|
64
|
+
projectRoot: string;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
executionId?: string;
|
|
67
|
+
query: string;
|
|
68
|
+
intent: string;
|
|
69
|
+
tokenBudget: number;
|
|
70
|
+
baselineReceipt: string;
|
|
71
|
+
expiresAt: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AppendRetrievalCycleInput {
|
|
75
|
+
retrievalSessionId: string;
|
|
76
|
+
projectRoot: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
baselineReceipt: string;
|
|
79
|
+
cycle: number;
|
|
80
|
+
gaps: RetrievalGapRecord[];
|
|
81
|
+
selectedIds: string[];
|
|
82
|
+
rejectedIds: string[];
|
|
83
|
+
tokenCost: number;
|
|
84
|
+
remainingTokenBudget: number;
|
|
85
|
+
quality: Record<string, unknown>;
|
|
86
|
+
receipt: string;
|
|
87
|
+
evidenceHash: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function mapRetrievalSessionRow(row: Record<string, unknown>): RetrievalSessionRecord {
|
|
91
|
+
const state = requiredString(row.state, 'state');
|
|
92
|
+
if (!isRetrievalSessionState(state)) {
|
|
93
|
+
throw new Error(`Invalid persisted retrieval session state: ${state}`);
|
|
94
|
+
}
|
|
95
|
+
const maxCycles = finiteInteger(row.max_cycles, 'max_cycles');
|
|
96
|
+
if (maxCycles !== RETRIEVAL_MAX_CYCLES) {
|
|
97
|
+
throw new Error(`Invalid persisted retrieval max_cycles: ${maxCycles}`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: requiredString(row.id, 'id'),
|
|
101
|
+
requestId: requiredString(row.request_id, 'request_id'),
|
|
102
|
+
projectRoot: requiredString(row.project_root, 'project_root'),
|
|
103
|
+
sessionId: requiredString(row.session_id, 'session_id'),
|
|
104
|
+
executionId: optionalString(row.execution_id),
|
|
105
|
+
query: requiredString(row.query, 'query'),
|
|
106
|
+
intent: requiredString(row.intent, 'intent'),
|
|
107
|
+
state,
|
|
108
|
+
cycle: finiteInteger(row.cycle, 'cycle'),
|
|
109
|
+
maxCycles: RETRIEVAL_MAX_CYCLES,
|
|
110
|
+
initialTokenBudget: finiteInteger(row.initial_token_budget, 'initial_token_budget'),
|
|
111
|
+
remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
|
|
112
|
+
baselineReceipt: requiredString(row.baseline_receipt, 'baseline_receipt'),
|
|
113
|
+
finalReceipt: optionalString(row.final_receipt),
|
|
114
|
+
createdAt: finiteInteger(row.created_at, 'created_at'),
|
|
115
|
+
updatedAt: finiteInteger(row.updated_at, 'updated_at'),
|
|
116
|
+
expiresAt: finiteInteger(row.expires_at, 'expires_at'),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mapRetrievalCycleRow(row: Record<string, unknown>): RetrievalCycleRecord {
|
|
121
|
+
return {
|
|
122
|
+
retrievalSessionId: requiredString(row.retrieval_session_id, 'retrieval_session_id'),
|
|
123
|
+
cycle: finiteInteger(row.cycle, 'cycle'),
|
|
124
|
+
gaps: parseJsonArray(row.gaps_json, 'gaps_json') as RetrievalGapRecord[],
|
|
125
|
+
selectedIds: parseStringArray(row.selected_ids, 'selected_ids'),
|
|
126
|
+
rejectedIds: parseStringArray(row.rejected_ids, 'rejected_ids'),
|
|
127
|
+
tokenCost: finiteInteger(row.token_cost, 'token_cost'),
|
|
128
|
+
remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
|
|
129
|
+
quality: parseJsonObject(row.quality_json, 'quality_json'),
|
|
130
|
+
receipt: requiredString(row.receipt, 'receipt'),
|
|
131
|
+
evidenceHash: requiredString(row.evidence_hash, 'evidence_hash'),
|
|
132
|
+
createdAt: finiteInteger(row.created_at, 'created_at'),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function serializeRetrievalJson(value: unknown): string {
|
|
137
|
+
return JSON.stringify(canonicalize(value));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isRetrievalSessionState(value: unknown): value is RetrievalSessionState {
|
|
141
|
+
return value === 'open' || value === 'satisfied' || value === 'exhausted' || value === 'expired';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function canonicalize(value: unknown): unknown {
|
|
145
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
146
|
+
if (typeof value === 'number') {
|
|
147
|
+
if (!Number.isFinite(value) || Object.is(value, -0)) throw new Error('Retrieval evidence must be lossless JSON');
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
151
|
+
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
152
|
+
throw new Error('Retrieval evidence must be plain JSON');
|
|
153
|
+
}
|
|
154
|
+
const record = value as Record<string, unknown>;
|
|
155
|
+
return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonicalize(record[key])]));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseJson(value: unknown, field: string): unknown {
|
|
159
|
+
if (typeof value !== 'string') throw new Error(`Invalid persisted ${field}: expected JSON text`);
|
|
160
|
+
try { return JSON.parse(value) as unknown; } catch { throw new Error(`Invalid persisted ${field}: malformed JSON`); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseJsonArray(value: unknown, field: string): unknown[] {
|
|
164
|
+
const parsed = parseJson(value, field);
|
|
165
|
+
if (!Array.isArray(parsed)) throw new Error(`Invalid persisted ${field}: expected array`);
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseStringArray(value: unknown, field: string): string[] {
|
|
170
|
+
const parsed = parseJsonArray(value, field);
|
|
171
|
+
if (parsed.some(item => typeof item !== 'string')) throw new Error(`Invalid persisted ${field}: expected strings`);
|
|
172
|
+
return parsed as string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseJsonObject(value: unknown, field: string): Record<string, unknown> {
|
|
176
|
+
const parsed = parseJson(value, field);
|
|
177
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
178
|
+
throw new Error(`Invalid persisted ${field}: expected object`);
|
|
179
|
+
}
|
|
180
|
+
return parsed as Record<string, unknown>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function requiredString(value: unknown, field: string): string {
|
|
184
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error(`Invalid persisted ${field}`);
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function optionalString(value: unknown): string | undefined {
|
|
189
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function finiteInteger(value: unknown, field: string): number {
|
|
193
|
+
const result = Number(value);
|
|
194
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`Invalid persisted ${field}`);
|
|
195
|
+
return result;
|
|
196
|
+
}
|