@feltdb/core 0.4.20 → 0.5.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/README.md +179 -0
- package/dist/cli/index.js +1 -1
- package/dist/collection.d.ts +45 -0
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +83 -1
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +12 -0
- package/dist/create/server-source/crates/feltdb/src/application.rs +183 -8
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +2 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +206 -45
- package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +381 -0
- package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +280 -0
- package/dist/error-codes.d.ts +53 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +46 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/memory-db.d.ts +12 -0
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +36 -0
- package/dist/revision-recovery.d.ts +162 -0
- package/dist/revision-recovery.d.ts.map +1 -0
- package/dist/revision-recovery.js +69 -0
- package/dist/state-contract.d.ts +2 -0
- package/dist/state-contract.d.ts.map +1 -1
- package/dist/state-contract.js +17 -7
- package/dist/studio-app/assets/{feltdb_wasm-CBGD0zRu.js → feltdb_wasm-DYPuS6Ky.js} +1 -1
- package/dist/studio-app/assets/{feltdb_wasm_bg-C6ATF9mJ.wasm → feltdb_wasm_bg-DEwA82pF.wasm} +0 -0
- package/dist/studio-app/assets/{index-CGQV6zGa.js → index-_qqVLfw1.js} +5 -5
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,185 @@ Browser mutations resolve after their IndexedDB transaction commits. The
|
|
|
75
75
|
durable change journal replays after reload and coordinates live collections
|
|
76
76
|
across tabs through `BroadcastChannel` when available.
|
|
77
77
|
|
|
78
|
+
## Durable Operation Management
|
|
79
|
+
|
|
80
|
+
FeltDB provides atomic operation admission and lifecycle management for systems that need to survive process crashes with guaranteed identity stability.
|
|
81
|
+
|
|
82
|
+
### Admit Operations (with atomic identity)
|
|
83
|
+
|
|
84
|
+
Guarantee: **exactly-once operation identity** across process crashes and concurrent callers.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { OperationAdmissionInput, DurableOperation } from '@feltdb/core';
|
|
88
|
+
|
|
89
|
+
const db = createFeltDB({ namespace: 'my-app', path: './state' });
|
|
90
|
+
|
|
91
|
+
const result = await db.admitOperation({
|
|
92
|
+
idempotencyKey: 'payment-123',
|
|
93
|
+
kind: 'payment-processing',
|
|
94
|
+
metadata: { amount: 99.99, currency: 'USD' }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Same idempotencyKey always returns same operationId
|
|
98
|
+
console.log(result.operationId); // 'op-xxx-yyy' (stable)
|
|
99
|
+
console.log(result.admitted); // true if this caller admitted it, false if already existed
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Transition Operations (atomic lifecycle)
|
|
103
|
+
|
|
104
|
+
Guarantee: **all-or-nothing state transitions** with version-based Compare-And-Set semantics.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { OperationTransitionInput } from '@feltdb/core';
|
|
108
|
+
|
|
109
|
+
const transition = await db.transitionOperation({
|
|
110
|
+
operationId: 'op-xxx-yyy',
|
|
111
|
+
expectedVersion: 0,
|
|
112
|
+
to: 'executing',
|
|
113
|
+
metadata: { started_at: Date.now() }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (transition.transitioned) {
|
|
117
|
+
// We won the transition race
|
|
118
|
+
console.log('Now executing...');
|
|
119
|
+
|
|
120
|
+
// Do work...
|
|
121
|
+
|
|
122
|
+
// Complete the operation
|
|
123
|
+
await db.transitionOperation({
|
|
124
|
+
operationId: 'op-xxx-yyy',
|
|
125
|
+
expectedVersion: 1,
|
|
126
|
+
to: 'completed',
|
|
127
|
+
resultSnapshot: { paymentId: 'pay-456', timestamp: Date.now() }
|
|
128
|
+
});
|
|
129
|
+
} else if (transition.reason === 'VERSION_CONFLICT') {
|
|
130
|
+
// Another process already transitioned this operation
|
|
131
|
+
console.log('Conflict - another process is handling this');
|
|
132
|
+
console.log('Current status:', transition.operation.status);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Operation lifecycle: `accepted` → `executing` → (`completed` | `failed` | `cancelled`)
|
|
137
|
+
|
|
138
|
+
Terminal states (`completed`, `failed`, `cancelled`) cannot be transitioned from.
|
|
139
|
+
|
|
140
|
+
### Recover Revisions (audited recovery from corruption)
|
|
141
|
+
|
|
142
|
+
Guarantee: **audit trail with permanent untrust markers**, no silent rollback.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { StateContractClient, RevisionRecoveryInput } from '@feltdb/core';
|
|
146
|
+
|
|
147
|
+
const client = new StateContractClient({
|
|
148
|
+
applicationId: 'my-app',
|
|
149
|
+
revisionId: 'rev-clean-122',
|
|
150
|
+
environment: 'staging'
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const recovery = await client.recoverApplicationRevision({
|
|
154
|
+
applicationId: 'my-app',
|
|
155
|
+
expectedCurrentRevision: 'rev-corrupted-123',
|
|
156
|
+
targetRevision: 'rev-clean-122',
|
|
157
|
+
authorization: 'ELEVATED',
|
|
158
|
+
actor: 'sherpa-admission',
|
|
159
|
+
reason: 'Replace corrupt historical revision',
|
|
160
|
+
environment: 'staging',
|
|
161
|
+
recoveryId: 'sherpa-staging-recovery-v1'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Result includes:
|
|
165
|
+
// - pointerMoved: Environment pointer moved to valid revision
|
|
166
|
+
// - sourceMarkedUntrusted: Corrupt revision permanently marked untrusted
|
|
167
|
+
// - auditDurable: Immutable audit trail persisted
|
|
168
|
+
console.log(recovery.pointerMoved); // true
|
|
169
|
+
console.log(recovery.sourceMarkedUntrusted); // true
|
|
170
|
+
console.log(recovery.auditDurable); // true
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Operation Types
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import {
|
|
177
|
+
OperationAdmissionInput,
|
|
178
|
+
OperationAdmissionResult,
|
|
179
|
+
DurableOperation,
|
|
180
|
+
OperationStatus,
|
|
181
|
+
OperationTransitionInput,
|
|
182
|
+
OperationTransitionResult,
|
|
183
|
+
} from '@feltdb/core';
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Error Semantics
|
|
187
|
+
|
|
188
|
+
All FeltDB APIs return **deterministic, semantic error codes** (never empty `{}`).
|
|
189
|
+
|
|
190
|
+
### Error Codes
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { FeltDBErrorCode } from '@feltdb/core';
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await db.transitionOperation({ ... });
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const felt_error = error.feltdb_error;
|
|
199
|
+
|
|
200
|
+
switch (felt_error.code) {
|
|
201
|
+
case FeltDBErrorCode.CONFLICT:
|
|
202
|
+
// Version mismatch; another process won the race
|
|
203
|
+
// → Retry with exponential backoff
|
|
204
|
+
console.log('Conflict; retrying...');
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case FeltDBErrorCode.PRECONDITION_FAILED:
|
|
208
|
+
// Validation or precondition error
|
|
209
|
+
// → Do not retry; fix the input
|
|
210
|
+
console.log('Validation error:', felt_error.message);
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case FeltDBErrorCode.TOO_BUSY:
|
|
214
|
+
// Queue depth exceeded
|
|
215
|
+
// → Retry with exponential backoff
|
|
216
|
+
console.log('Server busy; retrying...');
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case FeltDBErrorCode.INTERNAL_ERROR:
|
|
220
|
+
// Server error
|
|
221
|
+
// → Log and escalate; audit trail in request_id
|
|
222
|
+
console.log('Server error:', felt_error.request_id);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Error Response Structure
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { FeltDBErrorResponse } from '@feltdb/core';
|
|
232
|
+
|
|
233
|
+
interface FeltDBErrorResponse {
|
|
234
|
+
code: FeltDBErrorCode | string; // Semantic code (CONFLICT, PRECONDITION_FAILED, etc.)
|
|
235
|
+
message: string; // Human-readable message
|
|
236
|
+
request_id: string; // Unique ID for debugging
|
|
237
|
+
transaction_id?: string; // If applicable
|
|
238
|
+
http_status: number; // HTTP status for routing
|
|
239
|
+
recovery_hint?: 'retry_backoff' | 'dont_retry' | 'check_queue_depth' | 'contact_support';
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Error Handling Utilities
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { isRetryableError, getRetryStrategy } from '@feltdb/core';
|
|
247
|
+
|
|
248
|
+
// Check if error should be retried
|
|
249
|
+
if (isRetryableError(error.feltdb_error.code)) {
|
|
250
|
+
const strategy = getRetryStrategy(error.feltdb_error.code);
|
|
251
|
+
if (strategy === 'exponential_backoff') {
|
|
252
|
+
// Wait with exponential backoff before retry
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
78
257
|
## Concurrency Model
|
|
79
258
|
|
|
80
259
|
FeltDB 0.4.3 uses a **single-writer, multi-reader** model:
|
package/dist/cli/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as path from 'path';
|
|
|
23
23
|
import * as readline from 'readline';
|
|
24
24
|
import { getClient } from './api-client.js';
|
|
25
25
|
import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
|
|
26
|
-
const VERSION = '0.
|
|
26
|
+
const VERSION = '0.5.1';
|
|
27
27
|
function prompt(question) {
|
|
28
28
|
const rl = readline.createInterface({
|
|
29
29
|
input: process.stdin,
|
package/dist/collection.d.ts
CHANGED
|
@@ -11,6 +11,20 @@ import type { JsDb } from './feltdb.js';
|
|
|
11
11
|
import type { IndexConfig } from './index-types.js';
|
|
12
12
|
export type Predicate<T> = (item: T) => boolean;
|
|
13
13
|
export type Subscriber<T> = (items: T[]) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Result of an atomic version-checked update operation.
|
|
16
|
+
* Represents either a successful commit or a version conflict.
|
|
17
|
+
*/
|
|
18
|
+
export interface UpdateIfVersionResult<T> {
|
|
19
|
+
/** Whether the update succeeded */
|
|
20
|
+
updated: boolean;
|
|
21
|
+
/** The updated item with new __version (only if updated=true) */
|
|
22
|
+
item?: T & {
|
|
23
|
+
__version: number;
|
|
24
|
+
};
|
|
25
|
+
/** Current version if update failed due to version mismatch */
|
|
26
|
+
currentVersion?: number;
|
|
27
|
+
}
|
|
14
28
|
/**
|
|
15
29
|
* A live collection that automatically updates when underlying data changes.
|
|
16
30
|
* Represents application state, not a one-time query result.
|
|
@@ -76,12 +90,43 @@ export declare class Collection<T> {
|
|
|
76
90
|
where(predicate: Predicate<T>): Collection<T>;
|
|
77
91
|
/**
|
|
78
92
|
* Insert a new record into this collection.
|
|
93
|
+
* Automatically initializes __version to 1 for durable atomic transitions.
|
|
79
94
|
*/
|
|
80
95
|
insert(data: Partial<T>, id?: string | number): Promise<string>;
|
|
81
96
|
/**
|
|
82
97
|
* Update a record in this collection.
|
|
83
98
|
*/
|
|
84
99
|
update(id: string | number, changes: Partial<T>): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Atomically update a record only if its version matches the expected version.
|
|
102
|
+
*
|
|
103
|
+
* Provides Compare-And-Set semantics for durable state transitions.
|
|
104
|
+
* The version check and update happen atomically at the backend boundary,
|
|
105
|
+
* ensuring exactly one writer succeeds when multiple writers race.
|
|
106
|
+
*
|
|
107
|
+
* @param id Record identifier
|
|
108
|
+
* @param expectedVersion The version you observed when you read this record
|
|
109
|
+
* @param updates Fields to update (does not include __version; version is auto-incremented)
|
|
110
|
+
* @returns UpdateIfVersionResult with either the updated item or conflict info
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* // Read the current state
|
|
114
|
+
* const handoff = await handoffs.get(handoffId);
|
|
115
|
+
*
|
|
116
|
+
* // Try to accept it atomically
|
|
117
|
+
* const result = await handoffs.updateIfVersion(
|
|
118
|
+
* handoffId,
|
|
119
|
+
* handoff.__version,
|
|
120
|
+
* { status: "accepted", acceptedAt: new Date().toISOString() }
|
|
121
|
+
* );
|
|
122
|
+
*
|
|
123
|
+
* if (result.updated) {
|
|
124
|
+
* console.log('Accepted at version', result.item.__version);
|
|
125
|
+
* } else {
|
|
126
|
+
* console.log('Conflict - another writer won at version', result.currentVersion);
|
|
127
|
+
* }
|
|
128
|
+
*/
|
|
129
|
+
updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T>): Promise<UpdateIfVersionResult<T>>;
|
|
85
130
|
/**
|
|
86
131
|
* Delete a record from this collection.
|
|
87
132
|
*/
|
package/dist/collection.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAIxC,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,kBAAkB,CAAC;AAEhE,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC;AAChD,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;AAEjD;;;;;GAKG;AACH,qBAAa,UAAU,CAAC,CAAC;IACvB,OAAO,CAAC,EAAE,CAAO;IACjB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,oBAAoB,CAA8B;IAC1D,OAAO,CAAC,gBAAgB,CAA6B;IACrD,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,YAAY,CAAoC;IACxD,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,aAAa,CAAS;gBAElB,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC;IAmBpF;;OAEG;YACW,oBAAoB;IAalC;;;OAGG;IACG,GAAG,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;IAKzB,0DAA0D;IACpD,IAAI,CAAC,KAAK,GAAE,OAAO,CAAC,CAAC,CAAM,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAQhD;;;;;OAKG;IACH,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI;IAWtC;;OAEG;IACH,WAAW,IAAI,WAAW,EAAE;IAI5B;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAYrC;;OAEG;IACG,eAAe,IAAI,OAAO,CAAC,OAAO,CAAC;IAazC;;OAEG;IACG,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAajD;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC;IAO7C
|
|
1
|
+
{"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
|
package/dist/collection.js
CHANGED
|
@@ -153,12 +153,13 @@ export class Collection {
|
|
|
153
153
|
}
|
|
154
154
|
/**
|
|
155
155
|
* Insert a new record into this collection.
|
|
156
|
+
* Automatically initializes __version to 1 for durable atomic transitions.
|
|
156
157
|
*/
|
|
157
158
|
async insert(data, id) {
|
|
158
159
|
const recordId = id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
159
160
|
const key = `${this.name}:${recordId}`;
|
|
160
161
|
const stored = typeof data === 'object' && data !== null
|
|
161
|
-
? { ...data, id: recordId }
|
|
162
|
+
? { ...data, id: recordId, __version: 1 }
|
|
162
163
|
: data;
|
|
163
164
|
const result = await this.db.insert(key, JSON.stringify(stored));
|
|
164
165
|
if (!result.success) {
|
|
@@ -205,6 +206,87 @@ export class Collection {
|
|
|
205
206
|
const graph = getReactiveDependencyGraph();
|
|
206
207
|
await graph.emitChange(this.name, change);
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Atomically update a record only if its version matches the expected version.
|
|
211
|
+
*
|
|
212
|
+
* Provides Compare-And-Set semantics for durable state transitions.
|
|
213
|
+
* The version check and update happen atomically at the backend boundary,
|
|
214
|
+
* ensuring exactly one writer succeeds when multiple writers race.
|
|
215
|
+
*
|
|
216
|
+
* @param id Record identifier
|
|
217
|
+
* @param expectedVersion The version you observed when you read this record
|
|
218
|
+
* @param updates Fields to update (does not include __version; version is auto-incremented)
|
|
219
|
+
* @returns UpdateIfVersionResult with either the updated item or conflict info
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* // Read the current state
|
|
223
|
+
* const handoff = await handoffs.get(handoffId);
|
|
224
|
+
*
|
|
225
|
+
* // Try to accept it atomically
|
|
226
|
+
* const result = await handoffs.updateIfVersion(
|
|
227
|
+
* handoffId,
|
|
228
|
+
* handoff.__version,
|
|
229
|
+
* { status: "accepted", acceptedAt: new Date().toISOString() }
|
|
230
|
+
* );
|
|
231
|
+
*
|
|
232
|
+
* if (result.updated) {
|
|
233
|
+
* console.log('Accepted at version', result.item.__version);
|
|
234
|
+
* } else {
|
|
235
|
+
* console.log('Conflict - another writer won at version', result.currentVersion);
|
|
236
|
+
* }
|
|
237
|
+
*/
|
|
238
|
+
async updateIfVersion(id, expectedVersion, updates) {
|
|
239
|
+
const current = await this.get(id);
|
|
240
|
+
if (!current) {
|
|
241
|
+
throw new Error(`Record ${id} not found`);
|
|
242
|
+
}
|
|
243
|
+
const currentVersion = current.__version ?? 1;
|
|
244
|
+
if (currentVersion !== expectedVersion) {
|
|
245
|
+
return {
|
|
246
|
+
updated: false,
|
|
247
|
+
currentVersion,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Prepare updated record with incremented version
|
|
251
|
+
// User updates should not include __version
|
|
252
|
+
const cleanUpdates = { ...updates };
|
|
253
|
+
delete cleanUpdates.__version;
|
|
254
|
+
const newVersion = currentVersion + 1;
|
|
255
|
+
const updated = { ...current, ...cleanUpdates, __version: newVersion };
|
|
256
|
+
const key = `${this.name}:${id}`;
|
|
257
|
+
// Use CAS for atomic version-checked update
|
|
258
|
+
if (!this.db.cas) {
|
|
259
|
+
throw new Error(`updateIfVersion is not supported by this FeltDB runtime. ` +
|
|
260
|
+
`Only FileJsDb (Node.js) and HTTP/Server backends support atomic version-checked updates.`);
|
|
261
|
+
}
|
|
262
|
+
const casResult = await this.db.cas({
|
|
263
|
+
key,
|
|
264
|
+
expectedVersion: currentVersion,
|
|
265
|
+
value: JSON.stringify(updated),
|
|
266
|
+
});
|
|
267
|
+
if (!casResult.updated) {
|
|
268
|
+
// Another writer won the race
|
|
269
|
+
return {
|
|
270
|
+
updated: false,
|
|
271
|
+
currentVersion: casResult.currentVersion,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// Update indexes
|
|
275
|
+
this.indexBackend.updateRecord(String(id), current, updated);
|
|
276
|
+
// Emit change through reactive dependency graph
|
|
277
|
+
const change = {
|
|
278
|
+
type: 'update',
|
|
279
|
+
key,
|
|
280
|
+
value: updated,
|
|
281
|
+
timestamp: Date.now(),
|
|
282
|
+
};
|
|
283
|
+
const graph = getReactiveDependencyGraph();
|
|
284
|
+
await graph.emitChange(this.name, change);
|
|
285
|
+
return {
|
|
286
|
+
updated: true,
|
|
287
|
+
item: updated,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
208
290
|
/**
|
|
209
291
|
* Delete a record from this collection.
|
|
210
292
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.1';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -524,6 +524,7 @@ dependencies = [
|
|
|
524
524
|
"tower-http",
|
|
525
525
|
"tracing",
|
|
526
526
|
"tracing-subscriber",
|
|
527
|
+
"uuid",
|
|
527
528
|
]
|
|
528
529
|
|
|
529
530
|
[[package]]
|
|
@@ -1904,6 +1905,17 @@ version = "1.0.4"
|
|
|
1904
1905
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1905
1906
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|
1906
1907
|
|
|
1908
|
+
[[package]]
|
|
1909
|
+
name = "uuid"
|
|
1910
|
+
version = "1.25.0"
|
|
1911
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1912
|
+
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
|
|
1913
|
+
dependencies = [
|
|
1914
|
+
"getrandom 0.4.3",
|
|
1915
|
+
"js-sys",
|
|
1916
|
+
"wasm-bindgen",
|
|
1917
|
+
]
|
|
1918
|
+
|
|
1907
1919
|
[[package]]
|
|
1908
1920
|
name = "valuable"
|
|
1909
1921
|
version = "0.1.1"
|
|
@@ -959,6 +959,18 @@ pub struct RevisionAuditEvent {
|
|
|
959
959
|
pub diff_hash: Option<String>,
|
|
960
960
|
}
|
|
961
961
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
962
|
+
pub struct RevisionRecovery {
|
|
963
|
+
pub recovery_id: String,
|
|
964
|
+
pub application_id: String,
|
|
965
|
+
pub environment: String,
|
|
966
|
+
pub source_revision: String,
|
|
967
|
+
pub target_revision: String,
|
|
968
|
+
pub approved_by: String,
|
|
969
|
+
pub reason: String,
|
|
970
|
+
pub authorization_level: String,
|
|
971
|
+
pub recovered_at: u64,
|
|
972
|
+
}
|
|
973
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
962
974
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
963
975
|
pub enum ChangeSafety {
|
|
964
976
|
Safe,
|
|
@@ -1099,6 +1111,10 @@ struct RevisionRecords {
|
|
|
1099
1111
|
previews: Vec<ApplicationPreview>,
|
|
1100
1112
|
environment_pointers: BTreeMap<String, BTreeMap<String, String>>,
|
|
1101
1113
|
audit: Vec<RevisionAuditEvent>,
|
|
1114
|
+
#[serde(default)]
|
|
1115
|
+
untrusted_revisions: BTreeMap<String, BTreeMap<String, String>>,
|
|
1116
|
+
#[serde(default)]
|
|
1117
|
+
recoveries: Vec<RevisionRecovery>,
|
|
1102
1118
|
}
|
|
1103
1119
|
#[derive(Clone)]
|
|
1104
1120
|
pub struct ApplicationStore {
|
|
@@ -1462,6 +1478,15 @@ impl ApplicationStore {
|
|
|
1462
1478
|
})
|
|
1463
1479
|
.ok_or("revision not found")?
|
|
1464
1480
|
.clone();
|
|
1481
|
+
if r.untrusted_revisions
|
|
1482
|
+
.get(app)
|
|
1483
|
+
.is_some_and(|values| values.contains_key(&revision.revision_id))
|
|
1484
|
+
{
|
|
1485
|
+
return Err("revision is permanently untrusted".into());
|
|
1486
|
+
}
|
|
1487
|
+
if manifest_hash(&revision.manifest)? != revision.manifest_hash {
|
|
1488
|
+
return Err("revision integrity check failed".into());
|
|
1489
|
+
}
|
|
1465
1490
|
if !revision
|
|
1466
1491
|
.manifest
|
|
1467
1492
|
.environments
|
|
@@ -1536,6 +1561,108 @@ impl ApplicationStore {
|
|
|
1536
1561
|
Ok(promotion)
|
|
1537
1562
|
}
|
|
1538
1563
|
|
|
1564
|
+
#[allow(clippy::too_many_arguments)]
|
|
1565
|
+
pub fn recover_environment_pointer(
|
|
1566
|
+
&self,
|
|
1567
|
+
tenant: &str,
|
|
1568
|
+
app: &str,
|
|
1569
|
+
environment: &str,
|
|
1570
|
+
expected_current_revision: &str,
|
|
1571
|
+
target_revision: &str,
|
|
1572
|
+
actor: &str,
|
|
1573
|
+
reason: &str,
|
|
1574
|
+
authorization_level: &str,
|
|
1575
|
+
recovery_id: &str,
|
|
1576
|
+
) -> Result<RevisionRecovery, String> {
|
|
1577
|
+
let mut records = self
|
|
1578
|
+
.records
|
|
1579
|
+
.write()
|
|
1580
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1581
|
+
if let Some(existing) = records
|
|
1582
|
+
.recoveries
|
|
1583
|
+
.iter()
|
|
1584
|
+
.find(|value| value.recovery_id == recovery_id)
|
|
1585
|
+
{
|
|
1586
|
+
if existing.application_id == app
|
|
1587
|
+
&& existing.environment == environment
|
|
1588
|
+
&& existing.source_revision == expected_current_revision
|
|
1589
|
+
&& existing.target_revision == target_revision
|
|
1590
|
+
{
|
|
1591
|
+
return Ok(existing.clone());
|
|
1592
|
+
}
|
|
1593
|
+
return Err("recovery_id_conflict".into());
|
|
1594
|
+
}
|
|
1595
|
+
let current = records
|
|
1596
|
+
.environment_pointers
|
|
1597
|
+
.get(app)
|
|
1598
|
+
.and_then(|values| values.get(environment))
|
|
1599
|
+
.cloned()
|
|
1600
|
+
.unwrap_or_default();
|
|
1601
|
+
if current != expected_current_revision {
|
|
1602
|
+
return Err(format!("expected_revision_mismatch:{current}"));
|
|
1603
|
+
}
|
|
1604
|
+
let target = records
|
|
1605
|
+
.revisions
|
|
1606
|
+
.iter()
|
|
1607
|
+
.find(|value| {
|
|
1608
|
+
value.tenant_id == tenant
|
|
1609
|
+
&& value.application_id == app
|
|
1610
|
+
&& (value.revision_id == target_revision
|
|
1611
|
+
|| value.revision_number.to_string() == target_revision)
|
|
1612
|
+
})
|
|
1613
|
+
.ok_or("target revision not found")?
|
|
1614
|
+
.clone();
|
|
1615
|
+
if manifest_hash(&target.manifest)? != target.manifest_hash {
|
|
1616
|
+
return Err("target revision integrity check failed".into());
|
|
1617
|
+
}
|
|
1618
|
+
if records
|
|
1619
|
+
.untrusted_revisions
|
|
1620
|
+
.get(app)
|
|
1621
|
+
.is_some_and(|values| values.contains_key(&target.revision_id))
|
|
1622
|
+
{
|
|
1623
|
+
return Err("target revision is permanently untrusted".into());
|
|
1624
|
+
}
|
|
1625
|
+
let recovery = RevisionRecovery {
|
|
1626
|
+
recovery_id: recovery_id.into(),
|
|
1627
|
+
application_id: app.into(),
|
|
1628
|
+
environment: environment.into(),
|
|
1629
|
+
source_revision: expected_current_revision.into(),
|
|
1630
|
+
target_revision: target.revision_id.clone(),
|
|
1631
|
+
approved_by: actor.into(),
|
|
1632
|
+
reason: reason.into(),
|
|
1633
|
+
authorization_level: authorization_level.into(),
|
|
1634
|
+
recovered_at: now(),
|
|
1635
|
+
};
|
|
1636
|
+
records
|
|
1637
|
+
.untrusted_revisions
|
|
1638
|
+
.entry(app.into())
|
|
1639
|
+
.or_default()
|
|
1640
|
+
.insert(expected_current_revision.into(), recovery_id.into());
|
|
1641
|
+
records
|
|
1642
|
+
.environment_pointers
|
|
1643
|
+
.entry(app.into())
|
|
1644
|
+
.or_default()
|
|
1645
|
+
.insert(environment.into(), target.revision_id.clone());
|
|
1646
|
+
records.recoveries.push(recovery.clone());
|
|
1647
|
+
Self::event(
|
|
1648
|
+
&mut records,
|
|
1649
|
+
"application.revision.recovered",
|
|
1650
|
+
tenant,
|
|
1651
|
+
app,
|
|
1652
|
+
actor,
|
|
1653
|
+
Some(&target),
|
|
1654
|
+
Some(expected_current_revision.into()),
|
|
1655
|
+
);
|
|
1656
|
+
if let Some(event) = records.audit.last_mut() {
|
|
1657
|
+
event.environment = Some(environment.into());
|
|
1658
|
+
event.from_revision = Some(expected_current_revision.into());
|
|
1659
|
+
event.to_revision = Some(target.revision_id);
|
|
1660
|
+
event.correlation_id = recovery_id.into();
|
|
1661
|
+
}
|
|
1662
|
+
self.persist(&records)?;
|
|
1663
|
+
Ok(recovery)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1539
1666
|
pub fn history(&self, tenant: &str, app: &str, environment: &str) -> Vec<RevisionPromotion> {
|
|
1540
1667
|
self.records
|
|
1541
1668
|
.read()
|
|
@@ -2148,6 +2275,41 @@ mod tests {
|
|
|
2148
2275
|
assert_eq!(s.pointers("a")["production"], r2.revision_id);
|
|
2149
2276
|
assert_eq!(s.history("t", "a", "production").len(), 2);
|
|
2150
2277
|
}
|
|
2278
|
+
|
|
2279
|
+
#[test]
|
|
2280
|
+
fn recovery_is_atomic_idempotent_durable_and_permanently_untrusts_source() {
|
|
2281
|
+
let s = store("revision-recovery");
|
|
2282
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2283
|
+
let corrupt = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2284
|
+
s.move_environment_pointer("t", "a", &corrupt.revision_id, "staging", None,
|
|
2285
|
+
"owner", "initial", true, false).unwrap();
|
|
2286
|
+
let d = s.create_draft("t", "a", "App", "owner", Some(&corrupt.revision_id)).unwrap();
|
|
2287
|
+
let clean = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2288
|
+
|
|
2289
|
+
// Historical source corruption must not prevent recovery away from it.
|
|
2290
|
+
{
|
|
2291
|
+
let mut records = s.records.write().unwrap();
|
|
2292
|
+
records.revisions.iter_mut().find(|value| value.revision_id == corrupt.revision_id)
|
|
2293
|
+
.unwrap().manifest.metadata.name = "corrupted without updating its integrity hash".into();
|
|
2294
|
+
s.persist(&records).unwrap();
|
|
2295
|
+
}
|
|
2296
|
+
let recovered = s.recover_environment_pointer("t", "a", "staging", &corrupt.revision_id,
|
|
2297
|
+
&clean.revision_id, "sherpa", "Replace corrupt historical revision", "ELEVATED",
|
|
2298
|
+
"recovery-1").unwrap();
|
|
2299
|
+
assert_eq!(recovered.target_revision, clean.revision_id);
|
|
2300
|
+
assert_eq!(s.recover_environment_pointer("t", "a", "staging", &corrupt.revision_id,
|
|
2301
|
+
&clean.revision_id, "sherpa", "Replace corrupt historical revision", "ELEVATED",
|
|
2302
|
+
"recovery-1").unwrap(), recovered);
|
|
2303
|
+
assert!(s.move_environment_pointer("t", "a", &corrupt.revision_id, "staging",
|
|
2304
|
+
Some(&clean.revision_id), "owner", "rollback", true, true).unwrap_err()
|
|
2305
|
+
.contains("untrusted"));
|
|
2306
|
+
|
|
2307
|
+
let reloaded = ApplicationStore::load(s.path.clone()).unwrap();
|
|
2308
|
+
assert_eq!(reloaded.pointers("a")["staging"], clean.revision_id);
|
|
2309
|
+
let records = reloaded.records.read().unwrap();
|
|
2310
|
+
assert_eq!(records.recoveries.len(), 1);
|
|
2311
|
+
assert!(records.untrusted_revisions["a"].contains_key(&corrupt.revision_id));
|
|
2312
|
+
}
|
|
2151
2313
|
#[test]
|
|
2152
2314
|
fn preview_remains_bound_when_production_moves() {
|
|
2153
2315
|
let s = store("preview-bound");
|
|
@@ -2280,7 +2442,11 @@ mod tests {
|
|
|
2280
2442
|
});
|
|
2281
2443
|
|
|
2282
2444
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2283
|
-
assert!(
|
|
2445
|
+
assert!(
|
|
2446
|
+
report.valid,
|
|
2447
|
+
"Policy with authenticated subject should be valid: {:?}",
|
|
2448
|
+
report.issues
|
|
2449
|
+
);
|
|
2284
2450
|
}
|
|
2285
2451
|
|
|
2286
2452
|
#[test]
|
|
@@ -2295,7 +2461,11 @@ mod tests {
|
|
|
2295
2461
|
});
|
|
2296
2462
|
|
|
2297
2463
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2298
|
-
assert!(
|
|
2464
|
+
assert!(
|
|
2465
|
+
report.valid,
|
|
2466
|
+
"Policy with owner subject should be valid: {:?}",
|
|
2467
|
+
report.issues
|
|
2468
|
+
);
|
|
2299
2469
|
}
|
|
2300
2470
|
|
|
2301
2471
|
#[test]
|
|
@@ -2310,12 +2480,14 @@ mod tests {
|
|
|
2310
2480
|
});
|
|
2311
2481
|
|
|
2312
2482
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2313
|
-
assert!(!report.valid, "Policy with unknown subject should be invalid");
|
|
2314
2483
|
assert!(
|
|
2315
|
-
report.
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2484
|
+
!report.valid,
|
|
2485
|
+
"Policy with unknown subject should be invalid"
|
|
2486
|
+
);
|
|
2487
|
+
assert!(
|
|
2488
|
+
report.issues.iter().any(|i| i.path.contains("BadPolicy")
|
|
2489
|
+
&& i.path.contains("read")
|
|
2490
|
+
&& i.message.contains("invalid policy subject")),
|
|
2319
2491
|
"Should have error about invalid policy subject"
|
|
2320
2492
|
);
|
|
2321
2493
|
}
|
|
@@ -2332,6 +2504,9 @@ mod tests {
|
|
|
2332
2504
|
});
|
|
2333
2505
|
|
|
2334
2506
|
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2335
|
-
assert!(
|
|
2507
|
+
assert!(
|
|
2508
|
+
report.valid,
|
|
2509
|
+
"Policy without subjects should be valid (backward compatible)"
|
|
2510
|
+
);
|
|
2336
2511
|
}
|
|
2337
2512
|
}
|
|
@@ -21,3 +21,4 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
|
|
|
21
21
|
tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }
|
|
22
22
|
tracing = "0.1"
|
|
23
23
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
24
|
+
uuid = { version = "1.6", features = ["v4"] }
|