@feltdb/core 0.5.0 → 0.5.2

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.
Files changed (52) hide show
  1. package/README.md +24 -14
  2. package/dist/cell.d.ts +233 -0
  3. package/dist/cell.d.ts.map +1 -0
  4. package/dist/cell.js +1078 -0
  5. package/dist/cli/index.js +1 -1
  6. package/dist/collection.d.ts +48 -1
  7. package/dist/collection.d.ts.map +1 -1
  8. package/dist/collection.js +95 -10
  9. package/dist/create/package-versions.js +1 -1
  10. package/dist/create/server-source/crates/feltdb/src/application.rs +183 -8
  11. package/dist/create/server-source/crates/feltdb/src/lib.rs +173 -0
  12. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +231 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/main.rs +314 -46
  14. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +1 -1
  15. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +219 -0
  16. package/dist/db.d.ts +16 -0
  17. package/dist/db.d.ts.map +1 -1
  18. package/dist/db.js +21 -0
  19. package/dist/feltdb.d.ts +7 -1
  20. package/dist/feltdb.d.ts.map +1 -1
  21. package/dist/file-db.d.ts +5 -0
  22. package/dist/file-db.d.ts.map +1 -1
  23. package/dist/file-db.js +72 -9
  24. package/dist/http-db.d.ts +13 -0
  25. package/dist/http-db.d.ts.map +1 -1
  26. package/dist/http-db.js +41 -0
  27. package/dist/http-server.d.ts +42 -0
  28. package/dist/http-server.d.ts.map +1 -0
  29. package/dist/http-server.js +182 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/indexeddb-conformance.spec.d.ts +21 -0
  34. package/dist/indexeddb-conformance.spec.d.ts.map +1 -0
  35. package/dist/indexeddb-conformance.spec.js +103 -0
  36. package/dist/indexeddb-db.d.ts +8 -0
  37. package/dist/indexeddb-db.d.ts.map +1 -1
  38. package/dist/indexeddb-db.js +69 -0
  39. package/dist/memory-db.d.ts +14 -0
  40. package/dist/memory-db.d.ts.map +1 -1
  41. package/dist/memory-db.js +42 -0
  42. package/dist/state-contract.d.ts +1 -1
  43. package/dist/state-contract.d.ts.map +1 -1
  44. package/dist/state-contract.js +1 -1
  45. package/dist/studio-app/assets/{feltdb_wasm-CBGD0zRu.js → feltdb_wasm-h9mxesnH.js} +1 -1
  46. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
  47. package/dist/studio-app/assets/index-BMaQv3zF.js +28 -0
  48. package/dist/studio-app/index.html +1 -1
  49. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  50. package/package.json +1 -1
  51. package/dist/studio-app/assets/feltdb_wasm_bg-C6ATF9mJ.wasm +0 -0
  52. package/dist/studio-app/assets/index-3cvTQ0Mv.js +0 -28
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.5.0';
26
+ const VERSION = '0.5.2';
27
27
  function prompt(question) {
28
28
  const rl = readline.createInterface({
29
29
  input: process.stdin,
@@ -11,6 +11,22 @@ 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
+ /** Structured backend conflict code, when available. */
28
+ conflictCode?: string;
29
+ }
14
30
  /**
15
31
  * A live collection that automatically updates when underlying data changes.
16
32
  * Represents application state, not a one-time query result.
@@ -33,7 +49,7 @@ export declare class Collection<T> {
33
49
  private indexBackend;
34
50
  private indexStore;
35
51
  private indexesLoaded;
36
- constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T>);
52
+ constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T>, loadIndexes?: boolean);
37
53
  /**
38
54
  * Load indexes that were persisted from a previous session.
39
55
  */
@@ -76,12 +92,43 @@ export declare class Collection<T> {
76
92
  where(predicate: Predicate<T>): Collection<T>;
77
93
  /**
78
94
  * Insert a new record into this collection.
95
+ * Automatically initializes __version to 1 for durable atomic transitions.
79
96
  */
80
97
  insert(data: Partial<T>, id?: string | number): Promise<string>;
81
98
  /**
82
99
  * Update a record in this collection.
83
100
  */
84
101
  update(id: string | number, changes: Partial<T>): Promise<void>;
102
+ /**
103
+ * Atomically update a record only if its version matches the expected version.
104
+ *
105
+ * Provides Compare-And-Set semantics for durable state transitions.
106
+ * The version check and update happen atomically at the backend boundary,
107
+ * ensuring exactly one writer succeeds when multiple writers race.
108
+ *
109
+ * @param id Record identifier
110
+ * @param expectedVersion The version you observed when you read this record
111
+ * @param updates Fields to update (does not include __version; version is auto-incremented)
112
+ * @returns UpdateIfVersionResult with either the updated item or conflict info
113
+ *
114
+ * @example
115
+ * // Read the current state
116
+ * const handoff = await handoffs.get(handoffId);
117
+ *
118
+ * // Try to accept it atomically
119
+ * const result = await handoffs.updateIfVersion(
120
+ * handoffId,
121
+ * handoff.__version,
122
+ * { status: "accepted", acceptedAt: new Date().toISOString() }
123
+ * );
124
+ *
125
+ * if (result.updated) {
126
+ * console.log('Accepted at version', result.item.__version);
127
+ * } else {
128
+ * console.log('Conflict - another writer won at version', result.currentVersion);
129
+ * }
130
+ */
131
+ updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T>, expectedEpoch?: number, expectedLeaseId?: string, rejectIfDiverged?: boolean, knownCurrent?: T): Promise<UpdateIfVersionResult<T>>;
85
132
  /**
86
133
  * Delete a record from this collection.
87
134
  */
@@ -1 +1 @@
1
- {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"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"}
@@ -17,7 +17,7 @@ import { IndexStore } from './index-store.js';
17
17
  * Uses reactive dependency graph instead of polling for efficient updates.
18
18
  */
19
19
  export class Collection {
20
- constructor(db, name, predicate, parent) {
20
+ constructor(db, name, predicate, parent, loadIndexes = true) {
21
21
  this.predicate = null;
22
22
  this.cache = [];
23
23
  this.subscribers = new Set();
@@ -42,7 +42,10 @@ export class Collection {
42
42
  graph.registerDependency(parent.collectionId, this.collectionId);
43
43
  }
44
44
  // Load persisted indexes asynchronously
45
- this.loadPersistedIndexes();
45
+ if (loadIndexes)
46
+ this.loadPersistedIndexes();
47
+ else
48
+ this.indexesLoaded = true;
46
49
  }
47
50
  /**
48
51
  * Load indexes that were persisted from a previous session.
@@ -131,13 +134,11 @@ export class Collection {
131
134
  async get(id) {
132
135
  const key = `${this.name}:${id}`;
133
136
  const result = await this.db.get(key);
134
- if (result.success && result.data) {
135
- try {
136
- return JSON.parse(result.data);
137
- }
138
- catch {
139
- return null;
140
- }
137
+ if (!result.success) {
138
+ throw new Error(result.error || `Failed to read record ${id}`);
139
+ }
140
+ if (result.data) {
141
+ return JSON.parse(result.data);
141
142
  }
142
143
  return null;
143
144
  }
@@ -153,12 +154,13 @@ export class Collection {
153
154
  }
154
155
  /**
155
156
  * Insert a new record into this collection.
157
+ * Automatically initializes __version to 1 for durable atomic transitions.
156
158
  */
157
159
  async insert(data, id) {
158
160
  const recordId = id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
159
161
  const key = `${this.name}:${recordId}`;
160
162
  const stored = typeof data === 'object' && data !== null
161
- ? { ...data, id: recordId }
163
+ ? { ...data, id: recordId, __version: 1 }
162
164
  : data;
163
165
  const result = await this.db.insert(key, JSON.stringify(stored));
164
166
  if (!result.success) {
@@ -205,6 +207,89 @@ export class Collection {
205
207
  const graph = getReactiveDependencyGraph();
206
208
  await graph.emitChange(this.name, change);
207
209
  }
210
+ /**
211
+ * Atomically update a record only if its version matches the expected version.
212
+ *
213
+ * Provides Compare-And-Set semantics for durable state transitions.
214
+ * The version check and update happen atomically at the backend boundary,
215
+ * ensuring exactly one writer succeeds when multiple writers race.
216
+ *
217
+ * @param id Record identifier
218
+ * @param expectedVersion The version you observed when you read this record
219
+ * @param updates Fields to update (does not include __version; version is auto-incremented)
220
+ * @returns UpdateIfVersionResult with either the updated item or conflict info
221
+ *
222
+ * @example
223
+ * // Read the current state
224
+ * const handoff = await handoffs.get(handoffId);
225
+ *
226
+ * // Try to accept it atomically
227
+ * const result = await handoffs.updateIfVersion(
228
+ * handoffId,
229
+ * handoff.__version,
230
+ * { status: "accepted", acceptedAt: new Date().toISOString() }
231
+ * );
232
+ *
233
+ * if (result.updated) {
234
+ * console.log('Accepted at version', result.item.__version);
235
+ * } else {
236
+ * console.log('Conflict - another writer won at version', result.currentVersion);
237
+ * }
238
+ */
239
+ async updateIfVersion(id, expectedVersion, updates, expectedEpoch, expectedLeaseId, rejectIfDiverged, knownCurrent) {
240
+ const current = knownCurrent ?? await this.get(id);
241
+ if (!current) {
242
+ throw new Error(`Record ${id} not found`);
243
+ }
244
+ // Prepare updated record (no version increment - backend will handle it)
245
+ // User updates should not include __version
246
+ const cleanUpdates = { ...updates };
247
+ delete cleanUpdates.__version;
248
+ const updated = { ...current, ...cleanUpdates };
249
+ const key = `${this.name}:${id}`;
250
+ // Use CAS for atomic version-checked update
251
+ if (!this.db.cas) {
252
+ throw new Error(`updateIfVersion is not supported by this FeltDB runtime. ` +
253
+ `Only FileJsDb (Node.js) and HTTP/Server backends support atomic version-checked updates.`);
254
+ }
255
+ // Delegate authoritative version and epoch check to the backend
256
+ // The backend will atomically verify both predicates
257
+ const casResult = await this.db.cas({
258
+ key,
259
+ expectedVersion,
260
+ expectedEpoch,
261
+ expectedLeaseId,
262
+ rejectIfDiverged,
263
+ value: JSON.stringify(updated),
264
+ });
265
+ if (!casResult.updated) {
266
+ // Another writer won the race
267
+ return {
268
+ updated: false,
269
+ currentVersion: casResult.currentVersion,
270
+ conflictCode: casResult.conflictCode,
271
+ };
272
+ }
273
+ // Use the item returned by cas() which has the correct incremented version
274
+ const finalItem = (casResult.item || updated);
275
+ // CAS operation on backend increments version atomically - update finalItem to match
276
+ finalItem.__version = casResult.currentVersion;
277
+ // Update indexes
278
+ this.indexBackend.updateRecord(String(id), current, finalItem);
279
+ // Emit change through reactive dependency graph
280
+ const change = {
281
+ type: 'update',
282
+ key,
283
+ value: finalItem,
284
+ timestamp: Date.now(),
285
+ };
286
+ const graph = getReactiveDependencyGraph();
287
+ await graph.emitChange(this.name, change);
288
+ return {
289
+ updated: true,
290
+ item: finalItem,
291
+ };
292
+ }
208
293
  /**
209
294
  * Delete a record from this collection.
210
295
  */
@@ -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.5.0';
3
+ export const FELTDB_PACKAGE_VERSION = '0.5.2';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -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!(report.valid, "Policy with authenticated subject should be valid: {:?}", report.issues);
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!(report.valid, "Policy with owner subject should be valid: {:?}", report.issues);
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.issues.iter().any(|i|
2316
- i.path.contains("BadPolicy") && i.path.contains("read") &&
2317
- i.message.contains("invalid policy subject")
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!(report.valid, "Policy without subjects should be valid (backward compatible)");
2507
+ assert!(
2508
+ report.valid,
2509
+ "Policy without subjects should be valid (backward compatible)"
2510
+ );
2336
2511
  }
2337
2512
  }