create-feltdb 0.7.3 → 0.7.4

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 (35) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/create.js +30 -6
  3. package/dist/managed-account.js +14 -7
  4. package/dist/package-versions.js +1 -1
  5. package/dist/server-source/crates/feltdb/src/application.rs +31 -1
  6. package/dist/server-source/crates/feltdb/src/authority_failover.rs +84 -6
  7. package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
  8. package/dist/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  9. package/dist/server-source/crates/feltdb-server/src/identity.rs +46 -3
  10. package/dist/server-source/crates/feltdb-server/src/main.rs +315 -16
  11. package/dist/server-source/crates/feltdb-server/src/tenancy.rs +20 -0
  12. package/dist/template/default-project/README.md +57 -0
  13. package/dist/template/default-project/agents/activity-assistant.ts +18 -0
  14. package/dist/template/default-project/agents/project-assistant.ts +19 -0
  15. package/dist/template/default-project/capabilities/activity-summary.ts +14 -0
  16. package/dist/template/default-project/capabilities/project-search.ts +16 -0
  17. package/dist/template/default-project/capabilities/project-summary.ts +15 -0
  18. package/dist/template/default-project/feltdb.flow +169 -0
  19. package/dist/template/default-project/src/App.tsx +20 -0
  20. package/dist/template/default-project/src/context/AuthContext.tsx +27 -0
  21. package/dist/template/default-project/src/feltdb.ts +87 -0
  22. package/dist/template/default-project/src/index.tsx +14 -0
  23. package/dist/template/default-project/src/pages/Activity.tsx +7 -0
  24. package/dist/template/default-project/src/pages/Agents.tsx +15 -0
  25. package/dist/template/default-project/src/pages/Dashboard.tsx +17 -0
  26. package/dist/template/default-project/src/pages/Invitations.tsx +11 -0
  27. package/dist/template/default-project/src/pages/Projects.tsx +11 -0
  28. package/dist/template/default-project/src/pages/SignIn.tsx +8 -0
  29. package/dist/template/default-project/src/pages/SignUp.tsx +8 -0
  30. package/dist/template/default-project/src/styles-application.css +21 -0
  31. package/dist/template/default-project/src/styles.css +1 -0
  32. package/dist/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  33. package/dist/template/default-project/workflows/invitation.ts +30 -0
  34. package/dist/template/default-project/workflows/project-created.ts +2 -0
  35. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -308,7 +308,7 @@ Learn more: https://github.com/rkendel1/feltdb`);
308
308
  await createProject({
309
309
  projectName,
310
310
  autoYes: shouldAutoYes,
311
- templatesDir: path.join(__dirname, '../template'),
311
+ templatesDir: path.join(__dirname, 'template'),
312
312
  runtime: options.runtime,
313
313
  framework: options.framework,
314
314
  distributed: options.distributed,
package/dist/create.js CHANGED
@@ -49,9 +49,12 @@ export async function createProject(options) {
49
49
  test: 'node --test',
50
50
  feltdb: 'feltdb',
51
51
  'feltdb:server': 'feltdb server',
52
- 'feltdb:connect': 'feltdb connect http://localhost:7700',
52
+ 'feltdb:connect': 'feltdb connect',
53
53
  'feltdb:status': 'feltdb status',
54
54
  'feltdb:studio': 'feltdb studio',
55
+ 'feltdb:publish': 'feltdb publish',
56
+ 'feltdb:ai': 'feltdb ai',
57
+ 'feltdb:sync': 'feltdb sync',
55
58
  'feltdb:validate': 'feltdb validate',
56
59
  'feltdb:diff': 'feltdb diff',
57
60
  'feltdb:deploy': 'feltdb deploy',
@@ -176,25 +179,26 @@ ${hasAgents ? ` agent WorkspaceAssistant {
176
179
  lib: ['ES2020', 'DOM'],
177
180
  declaration: true,
178
181
  outDir: './dist',
179
- rootDir: './src',
182
+ rootDir: '.',
180
183
  strict: true,
181
184
  esModuleInterop: true,
182
185
  skipLibCheck: true,
183
186
  forceConsistentCasingInFileNames: true,
184
- moduleResolution: 'node',
187
+ moduleResolution: 'bundler',
185
188
  },
186
- include: ['src/**/*'],
189
+ include: framework === 'react' ? ['src/**/*', 'agents/**/*', 'capabilities/**/*', 'workflows/**/*'] : ['src/**/*'],
187
190
  exclude: ['node_modules'],
188
191
  };
189
192
  if (framework === 'react') {
190
193
  tsconfig.compilerOptions.jsx = 'react-jsx';
194
+ tsconfig.compilerOptions.types = ['vite/client'];
191
195
  }
192
196
  fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
193
197
  // Create main application files
194
198
  const runtimeOptions = runtime === 'browser'
195
199
  ? "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', browser: true }"
196
200
  : runtime === 'managed'
197
- ? "{ namespace: import.meta.env.VITE_FELTDB_MANAGED_NAMESPACE || import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_MANAGED_URL || import.meta.env.VITE_FELTDB_URL || '', token: import.meta.env.VITE_FELTDB_MANAGED_API_KEY || import.meta.env.VITE_FELTDB_API_KEY || '' } }"
201
+ ? "{ namespace: import.meta.env.VITE_FELTDB_MANAGED_NAMESPACE || import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_MANAGED_URL || import.meta.env.VITE_FELTDB_URL || '', token: import.meta.env.VITE_FELTDB_MANAGED_API_KEY || import.meta.env.VITE_FELTDB_API_KEY || '', applicationId: import.meta.env.VITE_FELTDB_MANAGED_APPLICATION_ID, environment: import.meta.env.VITE_FELTDB_MANAGED_ENVIRONMENT || 'production' } }"
198
202
  : runtime === 'self-hosted'
199
203
  ? "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_URL || 'http://localhost:7700', token: import.meta.env.VITE_FELTDB_API_KEY || '' } }"
200
204
  : "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', memory: true }";
@@ -365,7 +369,7 @@ async function logActivity(event: Omit<ActivityEvent, 'id' | 'timestamp'>): Prom
365
369
  `;
366
370
  fs.writeFileSync(path.join(srcDir, 'feltdb.ts'), feltdbTs);
367
371
  // Create a real local-inference agent for browser projects.
368
- if (hasAgents && hasWebLLM) {
372
+ if (hasAgents && hasWebLLM && framework !== 'react') {
369
373
  const agentTs = `import { WebLLMProvider } from '@feltdb/webllm';
370
374
  import { db, reports } from '../../src/feltdb';
371
375
 
@@ -1185,6 +1189,17 @@ root.render(
1185
1189
  );
1186
1190
  `;
1187
1191
  fs.writeFileSync(path.join(srcDir, 'index.tsx'), indexTsx);
1192
+ const canonicalTemplate = path.join(templatesDir, 'default-project');
1193
+ if (fs.existsSync(canonicalTemplate)) {
1194
+ fs.cpSync(path.join(canonicalTemplate, 'src'), srcDir, { recursive: true });
1195
+ for (const directory of ['agents', 'capabilities', 'workflows']) {
1196
+ fs.cpSync(path.join(canonicalTemplate, directory), path.join(projectDir, directory), { recursive: true });
1197
+ fs.rmSync(path.join(feltdbDir, directory), { recursive: true, force: true });
1198
+ }
1199
+ fs.writeFileSync(path.join(projectDir, 'feltdb.flow'), fs.readFileSync(path.join(canonicalTemplate, 'feltdb.flow'), 'utf8').replace('app FeltDBStarter', `app ${appName}`));
1200
+ const databasePath = path.join(srcDir, 'feltdb.ts');
1201
+ fs.writeFileSync(databasePath, fs.readFileSync(databasePath, 'utf8').replace(/export const managedRuntime[\s\S]*?\n\} : \{ namespace: import\.meta\.env\.VITE_FELTDB_NAMESPACE \|\| 'feltdb-starter', browser: true \}\);/, `export const managedRuntime = ${runtime === 'managed'};\nexport const db = createFeltDB(${runtimeOptions});`));
1202
+ }
1188
1203
  }
1189
1204
  else {
1190
1205
  // Create vanilla JS app
@@ -1229,6 +1244,8 @@ VITE_FELTDB_URL=http://localhost:7700
1229
1244
  # Managed example: https://runtime.your-app.feltdb.com
1230
1245
  VITE_FELTDB_MANAGED_URL=
1231
1246
  VITE_FELTDB_MANAGED_API_KEY=
1247
+ # Server/control-plane only. Never rename with a VITE_ prefix.
1248
+ FELTDB_MANAGED_CONTROL_API_KEY=
1232
1249
  VITE_FELTDB_MANAGED_TENANT_ID=
1233
1250
  VITE_FELTDB_MANAGED_APPLICATION_ID=
1234
1251
  VITE_FELTDB_MANAGED_NAMESPACE=
@@ -1524,6 +1541,7 @@ build/
1524
1541
  *.log
1525
1542
  .DS_Store
1526
1543
  .feltdb/
1544
+ .feltdb-data/
1527
1545
  `;
1528
1546
  fs.writeFileSync(path.join(projectDir, '.gitignore'), gitignore);
1529
1547
  // Create README
@@ -1795,6 +1813,12 @@ If port 5173 is in use:
1795
1813
  MIT
1796
1814
  `;
1797
1815
  fs.writeFileSync(path.join(projectDir, 'README.md'), readme);
1816
+ if (framework === 'react') {
1817
+ const canonicalReadme = path.join(templatesDir, 'default-project', 'README.md');
1818
+ if (fs.existsSync(canonicalReadme)) {
1819
+ fs.writeFileSync(path.join(projectDir, 'README.md'), fs.readFileSync(canonicalReadme, 'utf8').replace('# My FeltDB App', `# ${applicationName}`));
1820
+ }
1821
+ }
1798
1822
  // Initialize Development Workspace
1799
1823
  // This creates .feltdb/workspace.json which enables all FeltDB-aware tools
1800
1824
  // (CLI, IDE, agents, browser extensions) to discover and connect to the
@@ -43,20 +43,27 @@ export async function configureManagedAccount(options) {
43
43
  // Provision through the account origin that issued the human session. The
44
44
  // resulting key is still used against apiUrl, but setup must not require a
45
45
  // browser session to be trusted by every managed data-plane hostname.
46
- const key = await responseJson(await request(`${sessionOrigin}/api/keys`, {
46
+ const controlKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
47
47
  method: 'POST', headers,
48
48
  body: JSON.stringify({
49
- id: `${options.applicationName}-cli`,
49
+ id: `${options.applicationName}-control`,
50
50
  namespace: options.namespace,
51
- scopes: ['state:read', 'state:write', 'events:read'],
51
+ scopes: [
52
+ 'application:read', 'application:write', 'application:revision:create',
53
+ 'application:revision:read', 'application:revision:promote',
54
+ 'application:environment:read', 'application:environment:write',
55
+ ],
52
56
  }),
53
- }), 'Could not create the managed application key');
54
- if (!key.secret)
55
- throw new Error('Managed key service did not return an application secret');
57
+ }), 'Could not create the managed control-plane key');
58
+ if (!controlKey.secret)
59
+ throw new Error('Managed key service did not return the control-plane credential');
56
60
  const environment = [
57
61
  '# Generated by create-feltdb managed setup. Do not commit this file.',
58
62
  `VITE_FELTDB_MANAGED_URL=${apiUrl}`,
59
- `VITE_FELTDB_MANAGED_API_KEY=${key.secret}`,
63
+ '# Browser authentication uses short-lived actor sessions; no long-lived data key is embedded.',
64
+ 'VITE_FELTDB_MANAGED_API_KEY=',
65
+ '# Control-plane credential. Never prefix this with VITE_ or expose it to browser code.',
66
+ `FELTDB_MANAGED_CONTROL_API_KEY=${controlKey.secret}`,
60
67
  `VITE_FELTDB_MANAGED_TENANT_ID=${tenant.id}`,
61
68
  `VITE_FELTDB_MANAGED_APPLICATION_ID=${application.id}`,
62
69
  `VITE_FELTDB_MANAGED_NAMESPACE=${application.namespace || options.namespace}`,
@@ -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.7.3';
3
+ export const FELTDB_PACKAGE_VERSION = '0.7.4';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -132,7 +132,10 @@ pub struct CapabilityBinding {
132
132
  pub name: String,
133
133
  #[serde(default)]
134
134
  pub provider: Option<String>,
135
+ #[serde(default = "public_capability_visibility")]
136
+ pub visibility: String,
135
137
  }
138
+ fn public_capability_visibility() -> String { "public".into() }
136
139
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137
140
  pub struct SecretReference {
138
141
  pub binding: String,
@@ -368,7 +371,14 @@ pub struct ValidationReport {
368
371
  }
369
372
 
370
373
  fn is_valid_policy_subject(subject: &str) -> bool {
371
- matches!(subject, "authenticated" | "owner" | "member")
374
+ subject.split('|').map(str::trim).all(|term| {
375
+ matches!(term, "authenticated" | "owner" | "member")
376
+ || term.strip_prefix("self(").and_then(|value| value.strip_suffix(')')).is_some_and(|field| {
377
+ let mut characters = field.chars();
378
+ characters.next().is_some_and(|value| value == '_' || value.is_ascii_alphabetic())
379
+ && characters.all(|value| value == '_' || value.is_ascii_alphanumeric())
380
+ })
381
+ })
372
382
  }
373
383
 
374
384
  fn unique(
@@ -540,6 +550,11 @@ pub fn validate_manifest(
540
550
  .iter()
541
551
  .map(|v| v.name.as_str())
542
552
  .collect();
553
+ for capability in &manifest.capabilities {
554
+ if !matches!(capability.visibility.as_str(), "public" | "agent" | "workflow" | "internal") {
555
+ issues.push(issue("semantic", format!("capabilities.{}.visibility", capability.name), "visibility must be public, agent, workflow, or internal"));
556
+ }
557
+ }
543
558
  let workflows: BTreeSet<_> = manifest.workflows.iter().map(|v| v.name.as_str()).collect();
544
559
  let queries: BTreeSet<_> = manifest.queries.iter().map(|v| v.name.as_str()).collect();
545
560
  let bindings: BTreeSet<_> = manifest
@@ -2468,6 +2483,21 @@ mod tests {
2468
2483
  );
2469
2484
  }
2470
2485
 
2486
+ #[test]
2487
+ fn policy_with_identity_and_membership_composition_is_valid() {
2488
+ let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
2489
+ manifest.policies.push(PolicyDefinition {
2490
+ name: "InvitationPolicy".into(),
2491
+ resource: "Invitation".into(),
2492
+ read: Some("self(user) | member".into()),
2493
+ write: Some("member".into()),
2494
+ capabilities: vec![],
2495
+ });
2496
+
2497
+ let report = validate_manifest(&manifest, "tenant", "app", None);
2498
+ assert!(report.valid, "composed identity policy should be valid: {:?}", report.issues);
2499
+ }
2500
+
2471
2501
  #[test]
2472
2502
  fn policy_with_invalid_subject_rejected() {
2473
2503
  let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
@@ -283,6 +283,10 @@ pub enum AuthorityError {
283
283
  provided: FencingToken,
284
284
  current: FencingToken,
285
285
  },
286
+ QuorumUnavailable {
287
+ live: usize,
288
+ required: usize,
289
+ },
286
290
  Io(String),
287
291
  }
288
292
 
@@ -296,6 +300,12 @@ impl std::fmt::Display for AuthorityError {
296
300
  Self::StaleToken { provided, current } => {
297
301
  write!(f, "stale token {provided}, current is {current}")
298
302
  }
303
+ Self::QuorumUnavailable { live, required } => {
304
+ write!(
305
+ f,
306
+ "authority quorum unavailable: {live} live, {required} required"
307
+ )
308
+ }
299
309
  Self::Io(msg) => write!(f, "authority store: {msg}"),
300
310
  }
301
311
  }
@@ -308,6 +318,20 @@ impl AuthorityStore {
308
318
  cluster_id: &str,
309
319
  local_node_id: &str,
310
320
  local_priority: u32,
321
+ ) -> Result<Self, AuthorityError> {
322
+ Self::open_with_failure_detector(path, cluster_id, local_node_id, local_priority, 1_000, 5)
323
+ }
324
+
325
+ /// Open with an explicitly configured failure detector. Production keeps
326
+ /// the conservative default above; process-level acceptance tests use a
327
+ /// shorter interval so failure is event-driven without multi-second sleeps.
328
+ pub fn open_with_failure_detector<P: AsRef<Path>>(
329
+ path: P,
330
+ cluster_id: &str,
331
+ local_node_id: &str,
332
+ local_priority: u32,
333
+ heartbeat_interval_ms: u64,
334
+ failure_threshold: u32,
311
335
  ) -> Result<Self, AuthorityError> {
312
336
  let path = path.as_ref().to_path_buf();
313
337
  let state = if path.exists() {
@@ -323,7 +347,7 @@ impl AuthorityStore {
323
347
  Ok(Self {
324
348
  path,
325
349
  state,
326
- heartbeats: HeartbeatState::new(1000, 5), // 1s heartbeat, 5 missed = failure
350
+ heartbeats: HeartbeatState::new(heartbeat_interval_ms, failure_threshold),
327
351
  })
328
352
  }
329
353
 
@@ -333,8 +357,8 @@ impl AuthorityStore {
333
357
  std::fs::create_dir_all(parent).map_err(|e| AuthorityError::Io(e.to_string()))?;
334
358
  }
335
359
  let temp = self.path.with_extension("tmp");
336
- let bytes =
337
- serde_json::to_vec_pretty(&self.state).map_err(|e| AuthorityError::Io(e.to_string()))?;
360
+ let bytes = serde_json::to_vec_pretty(&self.state)
361
+ .map_err(|e| AuthorityError::Io(e.to_string()))?;
338
362
  {
339
363
  use std::io::Write;
340
364
  let mut file =
@@ -416,7 +440,9 @@ impl AuthorityStore {
416
440
 
417
441
  let mut record =
418
442
  AuthorityRecord::new(self.state.local_node_id.clone(), new_token, previous);
419
- record.acknowledgements.insert(self.state.local_node_id.clone());
443
+ record
444
+ .acknowledgements
445
+ .insert(self.state.local_node_id.clone());
420
446
 
421
447
  self.state.current_authority = Some(record);
422
448
  self.state.local_role = AuthorityRole::Authority;
@@ -512,6 +538,31 @@ impl AuthorityStore {
512
538
  Ok(())
513
539
  }
514
540
 
541
+ /// Validate an authoritative write against both its fencing term and a
542
+ /// live quorum. A partitioned former authority may not have observed the
543
+ /// replacement's newer token yet, so fencing alone is insufficient until
544
+ /// healing; expiry of its quorum evidence closes that split-brain window.
545
+ pub fn validate_quorum_write(
546
+ &self,
547
+ token: FencingToken,
548
+ required_quorum: usize,
549
+ ) -> Result<(), AuthorityError> {
550
+ self.validate_write(Some(token))?;
551
+ let live = 1 + self
552
+ .heartbeats
553
+ .last_seen
554
+ .keys()
555
+ .filter(|node_id| !self.heartbeats.is_failed(node_id))
556
+ .count();
557
+ if live < required_quorum {
558
+ return Err(AuthorityError::QuorumUnavailable {
559
+ live,
560
+ required: required_quorum,
561
+ });
562
+ }
563
+ Ok(())
564
+ }
565
+
515
566
  /// Step down from authority role (voluntary).
516
567
  pub fn step_down(&mut self) -> Result<(), AuthorityError> {
517
568
  if self.state.local_role == AuthorityRole::Authority {
@@ -746,13 +797,40 @@ mod tests {
746
797
  assert!(heartbeats.is_failed("A"));
747
798
  }
748
799
 
800
+ #[test]
801
+ fn partitioned_authority_loses_write_quorum() {
802
+ let dir = tempfile::tempdir().unwrap();
803
+ let mut store = AuthorityStore::open_with_failure_detector(
804
+ dir.path().join("authority.json"),
805
+ "test-cluster",
806
+ "A",
807
+ 10,
808
+ 1,
809
+ 1,
810
+ )
811
+ .expect("open authority store");
812
+ let token = match store.claim_authority().expect("claim") {
813
+ ClaimResult::Granted { token } => token,
814
+ _ => panic!("authority claim was not granted"),
815
+ };
816
+ store.heartbeat("B");
817
+ assert!(store.validate_quorum_write(token, 2).is_ok());
818
+ store.heartbeats.last_seen.insert("B".to_string(), 0);
819
+ assert!(matches!(
820
+ store.validate_quorum_write(token, 2),
821
+ Err(AuthorityError::QuorumUnavailable {
822
+ live: 1,
823
+ required: 2
824
+ })
825
+ ));
826
+ }
827
+
749
828
  #[test]
750
829
  fn recovery_from_fenced() {
751
830
  let dir = tempfile::tempdir().unwrap();
752
831
  let path = dir.path().join("authority.json");
753
832
 
754
- let mut store =
755
- AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
833
+ let mut store = AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
756
834
  store.claim_authority().expect("claim");
757
835
 
758
836
  // Get fenced