1use crate::{
5 CustomRouteDefinition, ScheduleDefinition, ServiceManifest, SubscriptionDefinition,
6 TableDefinition,
7};
8use std::collections::BTreeSet;
9
10pub const ROUTE_POSTURE: &str = "route_posture";
11pub const DB_V2: &str = "db_v2";
12pub const CACHE: &str = "cache";
13pub const SERVICE_CALL: &str = "service_call";
14pub const RESPONSE_V2: &str = "response_v2";
15pub const SSE_BRIDGE: &str = "sse_bridge";
16pub const ROUTE_MATCHING: &str = "route_matching";
17pub const ROUTE_LIMITS: &str = "route_limits";
18pub const ERROR_PROFILE: &str = "error_profile";
19pub const INTERNAL_POSTURE: &str = "internal_posture";
20pub const API_KEY_POSTURE: &str = "api_key_posture";
21pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
22pub const SCOPED_POSTURE: &str = "scoped_posture";
23pub const TYPED_ROUTES: &str = "typed_routes";
24pub const NATS_CONSUMER_CONFIG: &str = "nats_consumer_config";
25pub const NATS_MESSAGE_META: &str = "nats_message_meta";
26pub const NATS_PUBLISH_V2: &str = "nats_publish_v2";
27pub const STREAMS: &str = "streams";
28pub const SCHEDULES: &str = "schedules";
29pub const JOBS_V2: &str = "jobs_v2";
30pub const DELIVERY_CONTROL: &str = "delivery_control";
31pub const HTTP_V2: &str = "http_v2";
32pub const STORAGE_V2: &str = "storage_v2";
33pub const UTIL_V2: &str = "util_v2";
34pub const SCHEMA_ADOPTED: &str = "schema_adopted";
35pub const SQL_MIGRATIONS: &str = "sql_migrations";
36pub const COLUMN_TYPES_V2: &str = "column_types_v2";
37pub const AUDIT_REDACTION: &str = "audit_redaction";
38pub const KEYSET_LISTS: &str = "keyset_lists";
39pub const OUTBOX: &str = "outbox";
40pub const DB_ROLES: &str = "db_roles";
41pub const SCHEMA_GUARD: &str = "schema_guard";
42pub const NAMESPACE_CLAIMS: &str = "namespace_claims";
43pub const SECRET_ENCRYPTION: &str = "secret_encryption";
44pub const CONSUMER_RECONCILER: &str = "consumer_reconciler";
45pub const ASYNC_DEPLOY: &str = "async_deploy";
46pub const EXT_CALL: &str = "ext_call";
47pub const NATIVE_SERVICES: &str = "native_services";
48pub const PRIVATE_TABLES: &str = "private_tables";
49pub const INACTIVE_DEPLOY: &str = "inactive_deploy";
50pub const CONFIG_APPLY: &str = "config_apply";
51pub const COLUMN_PERMISSIONS: &str = "column_permissions";
52
53impl ServiceManifest {
54 pub fn derived_features(&self) -> BTreeSet<&'static str> {
59 self.custom_routes
60 .iter()
61 .flat_map(CustomRouteDefinition::derived_features)
62 .chain(
63 self.declared_tables()
64 .flat_map(TableDefinition::derived_features),
65 )
66 .chain(
67 self.subscriptions
68 .iter()
69 .flat_map(SubscriptionDefinition::derived_features),
70 )
71 .chain(
72 self.schedules
73 .iter()
74 .flat_map(ScheduleDefinition::derived_features),
75 )
76 .chain(self.service_features())
77 .collect()
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::manifest::fixtures::sample_manifest;
85 use crate::RoutePosture;
86 use serde_json::Value;
87
88 const STARMOIRE: &str = include_str!("../fixtures/starmoire_manifest.json");
89 const TODO: &str = include_str!("../fixtures/todo_manifest.json");
90 const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
91 const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
96
97 fn manifest(json: &str) -> ServiceManifest {
98 serde_json::from_str(json).expect("fixture manifest")
99 }
100
101 fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
102 CustomRouteDefinition {
103 method: "GET".into(),
104 path: format!("/{handler}"),
105 handler: handler.into(),
106 job: None,
107 posture,
108 forward_auth: false,
109 ..Default::default()
110 }
111 }
112
113 #[test]
114 fn starmoire_manifest_derives_only_route_posture() {
115 let starmoire = manifest(STARMOIRE);
116 assert_eq!(
117 starmoire.derived_features(),
118 BTreeSet::from([ROUTE_POSTURE])
119 );
120 assert_eq!(starmoire.required_platform_features, vec![ROUTE_POSTURE]);
121 for crud_only in [TODO, BLOG] {
122 assert!(manifest(crud_only).derived_features().is_empty());
123 }
124 }
125
126 #[test]
127 fn route_posture_is_derived_once_from_any_declared_posture() {
128 let mut m = sample_manifest();
129 assert!(m.derived_features().is_empty());
130 m.custom_routes.push(route("legacy", None));
131 assert!(m.derived_features().is_empty());
132 m.custom_routes
133 .push(route("me", Some(RoutePosture::Authenticated)));
134 m.custom_routes.push(route(
135 "hook",
136 Some(RoutePosture::Public {
137 reason: "signed webhook".into(),
138 }),
139 ));
140 assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
141 }
142
143 #[test]
144 fn strict_routing_demands_route_matching() {
145 let mut m = sample_manifest();
146 m.routing = crate::RoutingMode::Strict;
147 assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_MATCHING]));
148 }
149
150 #[test]
151 fn an_error_profile_demands_error_profile() {
152 let mut m = sample_manifest();
153 m.errors = Some(crate::ErrorProfile {
154 key: "error_message".into(),
155 });
156 assert_eq!(m.derived_features(), BTreeSet::from([ERROR_PROFILE]));
157 }
158
159 fn sql_migration() -> crate::SqlMigration {
160 crate::SqlMigration {
161 version: 1,
162 name: "initial_tables".into(),
163 sql: "CREATE TABLE notes ();".into(),
164 no_transaction: false,
165 }
166 }
167
168 fn stream() -> crate::StreamDefinition {
169 serde_json::from_value(serde_json::json!({
170 "name": "NOTES", "subjects": ["notes.created"], "retention": "limits"
171 }))
172 .expect("stream")
173 }
174
175 fn schedule() -> crate::ScheduleDefinition {
176 serde_json::from_value(serde_json::json!({
177 "name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}
178 }))
179 .expect("schedule")
180 }
181
182 fn private_table() -> TableDefinition {
183 serde_json::from_value(serde_json::json!({
184 "name": "notes", "managed": false,
185 "columns": [{"name": "body", "column_type": "text"}]
186 }))
187 .expect("table")
188 }
189
190 fn r3_fields() -> Vec<(
194 &'static str,
195 ServiceManifest,
196 ServiceManifest,
197 Vec<&'static str>,
198 )> {
199 use crate::{
200 AckMode, ConsumerNaming, HandlerType, JobConfig, MigrationHistory, Pagination, RetryOn,
201 SchemaOwnership,
202 };
203 let base = || manifest(STARMOIRE);
204 let subscribed = || {
205 let mut m = base();
206 m.subscriptions.insert(
207 0,
208 SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm),
209 );
210 m
211 };
212 let service =
213 |pointer, after: ServiceManifest, features| (pointer, base(), after, features);
214 let table = |pointer, edit: fn(&mut TableDefinition), features| {
215 let mut after = base();
216 edit(&mut after.tables[0]);
217 (pointer, base(), after, features)
218 };
219 let route = |pointer, edit: fn(&mut crate::CustomRouteDefinition), features| {
220 let mut after = base();
221 edit(&mut after.custom_routes[0]);
222 (pointer, base(), after, features)
223 };
224 let subscription = |pointer, edit: fn(&mut SubscriptionDefinition), features| {
225 let mut after = subscribed();
226 edit(&mut after.subscriptions[0]);
227 (pointer, subscribed(), after, features)
228 };
229 vec![
230 service(
231 "/consumers",
232 ServiceManifest {
233 consumers: ConsumerNaming::Scoped,
234 ..base()
235 },
236 vec![NATS_CONSUMER_CONFIG],
237 ),
238 service(
239 "/streams",
240 ServiceManifest {
241 streams: vec![stream()],
242 ..base()
243 },
244 vec![STREAMS],
245 ),
246 service(
247 "/schedules",
248 ServiceManifest {
249 schedules: vec![schedule()],
250 ..base()
251 },
252 vec![SCHEDULES],
253 ),
254 service(
255 "/schedules/0/job/retry_on",
256 ServiceManifest {
257 schedules: vec![crate::ScheduleDefinition {
258 job: Some(JobConfig {
259 retry_on: vec![RetryOn::Timeout],
260 ..Default::default()
261 }),
262 ..schedule()
263 }],
264 ..base()
265 },
266 vec![SCHEDULES, JOBS_V2],
267 ),
268 service(
269 "/schema",
270 ServiceManifest {
271 schema: SchemaOwnership::Adopted,
272 ..base()
273 },
274 vec![SCHEMA_ADOPTED],
275 ),
276 service(
277 "/schema_name",
278 ServiceManifest {
279 schema_name: Some("note_service".into()),
280 ..base()
281 },
282 vec![SCHEMA_ADOPTED],
283 ),
284 service(
285 "/migrations",
286 ServiceManifest {
287 migrations: vec![sql_migration()],
288 ..base()
289 },
290 vec![SQL_MIGRATIONS],
291 ),
292 service(
293 "/extensions",
294 ServiceManifest {
295 extensions: vec!["google.token".into()],
296 ..base()
297 },
298 vec![EXT_CALL],
299 ),
300 service(
301 "/provides",
302 ServiceManifest {
303 mode: crate::ServiceMode::Native,
304 provides: vec![crate::ProvidedExtension {
305 name: "google.token".into(),
306 callers: vec!["reports-service".into()],
307 }],
308 ..base()
309 },
310 vec![NATIVE_SERVICES],
311 ),
312 service(
313 "/migration_history",
314 ServiceManifest {
315 migration_history: Some(MigrationHistory::default()),
316 ..base()
317 },
318 vec![],
319 ),
320 subscription(
321 "/subscriptions/0/ack",
322 |s| s.ack = AckMode::OnSuccess,
323 vec![NATS_CONSUMER_CONFIG],
324 ),
325 subscription(
326 "/subscriptions/0/consumer",
327 |s| s.consumer = Some(Default::default()),
328 vec![NATS_CONSUMER_CONFIG],
329 ),
330 subscription(
331 "/subscriptions/0/timeout_secs",
332 |s| s.timeout_secs = Some(30),
333 vec![NATS_CONSUMER_CONFIG],
334 ),
335 table(
336 "/tables/0/managed",
337 |t| t.managed = false,
338 vec![SCHEMA_ADOPTED],
339 ),
340 table(
341 "/tables/0/checks",
342 |t| {
343 t.checks.push(crate::CheckConstraint {
344 name: "c".into(),
345 expr: "a > 0".into(),
346 })
347 },
348 vec![COLUMN_TYPES_V2],
349 ),
350 table(
351 "/tables/0/search",
352 |t| t.search = Some(Default::default()),
353 vec![COLUMN_TYPES_V2],
354 ),
355 table(
356 "/tables/0/indexes/0/predicate",
357 |t| {
358 t.indexes.insert(
359 0,
360 crate::IndexDefinition {
361 name: "i".into(),
362 columns: vec!["id".into()],
363 predicate: Some("id IS NOT NULL".into()),
364 ..Default::default()
365 },
366 )
367 },
368 vec![COLUMN_TYPES_V2],
369 ),
370 table(
371 "/tables/0/indexes/0/method",
372 |t| {
373 t.indexes.insert(
374 0,
375 crate::IndexDefinition {
376 name: "i".into(),
377 columns: vec!["id".into()],
378 method: crate::IndexMethod::Hash,
379 ..Default::default()
380 },
381 )
382 },
383 vec![COLUMN_TYPES_V2],
384 ),
385 table(
386 "/tables/0/audit_redact",
387 |t| t.audit_redact.push("dob".into()),
388 vec![AUDIT_REDACTION],
389 ),
390 table(
391 "/tables/0/sensitive",
392 |t| t.sensitive = true,
393 vec![AUDIT_REDACTION],
394 ),
395 route(
396 "/custom_routes/0/audit_redact",
397 |r| r.audit_redact.push("card.number".into()),
398 vec![AUDIT_REDACTION],
399 ),
400 table(
401 "/tables/0/pagination",
402 |t| t.pagination = Pagination::Keyset,
403 vec![KEYSET_LISTS],
404 ),
405 table(
406 "/tables/0/columns/1/access",
407 |t| {
408 t.columns[1].access = Some(crate::ColumnAccess {
409 read: Some("internal".into()),
410 write: None,
411 })
412 },
413 vec![COLUMN_PERMISSIONS],
414 ),
415 service(
416 "/private_tables",
417 ServiceManifest {
418 private_tables: vec![private_table()],
419 ..base()
420 },
421 vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
422 ),
423 ]
424 }
425
426 fn json(manifest: &ServiceManifest) -> Value {
427 serde_json::to_value(manifest).expect("serialize")
428 }
429
430 #[test]
431 fn every_r3_field_is_absent_until_it_is_set() {
432 for (pointer, before, after, _) in r3_fields() {
433 assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
434 assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
435 }
436 }
437
438 #[test]
439 fn every_r3_field_changes_the_hash_and_round_trips() {
440 for (pointer, before, after, _) in r3_fields() {
441 assert_ne!(
442 after.deploy_hash().expect("hash"),
443 before.deploy_hash().expect("hash"),
444 "{pointer}"
445 );
446 let wire = json(&after);
447 let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
448 assert_eq!(json(&reparsed), wire, "{pointer}");
449 }
450 }
451
452 #[test]
453 fn every_r3_field_demands_its_features() {
454 for (pointer, before, after, demanded) in r3_fields() {
455 assert_eq!(
456 before.derived_features(),
457 BTreeSet::from([ROUTE_POSTURE]),
458 "{pointer}"
459 );
460 let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
461 assert_eq!(after.derived_features(), expected, "{pointer}");
462 }
463 }
464
465 #[test]
466 fn deploy_hash_unchanged_for_legacy_manifest() {
467 for (json, deploy, schema) in [
468 (
469 STARMOIRE,
470 "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
471 "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
472 ),
473 (
474 TODO,
475 "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
476 "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
477 ),
478 (
479 BLOG,
480 "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
481 "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
482 ),
483 (
484 LEGACY_SHAPES,
485 "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
486 "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
487 ),
488 ] {
489 let m = manifest(json);
490 assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
491 assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
492 }
493 }
494
495 #[test]
496 fn a_manifest_without_private_tables_serialises_as_before() {
497 for json in [STARMOIRE, TODO, BLOG, LEGACY_SHAPES] {
498 let stored: Value = serde_json::from_str(json).expect("fixture json");
499 let m = manifest(json);
500 assert!(m.private_tables.is_empty(), "{}", m.name);
501 let wire = serde_json::to_value(&m).expect("serialize");
502 assert_eq!(wire.get("private_tables"), None, "{}", m.name);
503 assert_eq!(wire, stored, "{}", m.name);
504 assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
505 }
506 }
507
508 #[test]
509 fn private_tables_demand_their_feature() {
510 let mut m = sample_manifest();
511 assert!(m.derived_features().is_empty());
512 m.private_tables = vec![private_table()];
513 assert_eq!(
514 m.derived_features(),
515 BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
516 );
517 m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
518 assert_eq!(
519 m.derived_features(),
520 BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
521 );
522 }
523
524 #[test]
525 fn legacy_manifest_json_round_trips_unchanged() {
526 for json in [STARMOIRE, TODO, BLOG, LEGACY_SHAPES] {
527 let stored: Value = serde_json::from_str(json).expect("fixture json");
528 let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
529 assert_eq!(reserialized, stored);
530 }
531 }
532
533 #[test]
534 fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
535 assert_eq!(
536 manifest(LEGACY_SHAPES).derived_features(),
537 BTreeSet::from([
538 "api_key_posture",
539 "error_profile",
540 "internal_posture",
541 "route_limits",
542 "route_matching",
543 "route_posture",
544 "scoped_permissions",
545 "scoped_posture",
546 "sse_bridge",
547 ])
548 );
549 }
550}