@fairfox/polly 0.10.0 → 0.11.0

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 CHANGED
@@ -343,6 +343,192 @@ test('user profile updates', async ({ page, extensionId }) => {
343
343
  })
344
344
  ```
345
345
 
346
+ ### Full-Stack SPAs with Elysia (Bun)
347
+
348
+ Polly provides first-class support for building full-stack web applications with Elysia and Bun, treating your SPA as a distributed system.
349
+
350
+ **Why?** Modern SPAs are distributed systems facing classic distributed computing problems: network unreliability, eventual consistency, offline behavior, cache invalidation, and the CAP theorem. The Elysia integration makes these concerns explicit and verifiable.
351
+
352
+ #### Server: Add Polly Middleware
353
+
354
+ ```typescript
355
+ // server/index.ts
356
+ import { Elysia, t } from 'elysia'
357
+ import { polly } from '@fairfox/polly/elysia'
358
+ import { $syncedState, $serverState } from '@fairfox/polly'
359
+
360
+ const app = new Elysia()
361
+ .use(polly({
362
+ // Define shared state
363
+ state: {
364
+ client: {
365
+ todos: $syncedState('todos', []),
366
+ user: $syncedState('user', null),
367
+ },
368
+ server: {
369
+ db: $serverState('db', database),
370
+ },
371
+ },
372
+
373
+ // Define client-side effects (what happens after server operations)
374
+ effects: {
375
+ 'POST /todos': {
376
+ client: ({ result, state }) => {
377
+ // Update client state with new todo
378
+ state.client.todos.value = [...state.client.todos.value, result]
379
+ },
380
+ broadcast: true, // Notify all connected clients
381
+ },
382
+ 'PATCH /todos/:id': {
383
+ client: ({ result, state }) => {
384
+ // Update specific todo in client state
385
+ state.client.todos.value = state.client.todos.value.map(t =>
386
+ t.id === result.id ? result : t
387
+ )
388
+ },
389
+ broadcast: true,
390
+ },
391
+ 'DELETE /todos/:id': {
392
+ client: ({ params, state }) => {
393
+ // Remove todo from client state
394
+ state.client.todos.value = state.client.todos.value.filter(
395
+ t => t.id !== Number(params.id)
396
+ )
397
+ },
398
+ broadcast: true,
399
+ },
400
+ },
401
+
402
+ // Define authorization rules
403
+ authorization: {
404
+ 'POST /todos': ({ state }) => state.client.user.value !== null,
405
+ 'PATCH /todos/:id': ({ state }) => state.client.user.value !== null,
406
+ 'DELETE /todos/:id': ({ state }) => state.client.user.value !== null,
407
+ },
408
+
409
+ // Configure offline behavior
410
+ offline: {
411
+ 'POST /todos': {
412
+ queue: true, // Queue when offline
413
+ optimistic: (body) => ({
414
+ id: -Date.now(), // Temporary ID
415
+ text: body.text,
416
+ completed: false,
417
+ }),
418
+ },
419
+ },
420
+
421
+ // Enable TLA+ generation for verification
422
+ tlaGeneration: true,
423
+ }))
424
+
425
+ // Write normal Elysia routes (no Polly annotations!)
426
+ .post('/todos', async ({ body, pollyState }) => {
427
+ const todo = await pollyState.server.db.value.todos.create(body)
428
+ return todo
429
+ }, {
430
+ body: t.Object({ text: t.String() })
431
+ })
432
+
433
+ .listen(3000)
434
+ ```
435
+
436
+ #### Client: Use Eden with Polly Wrapper
437
+
438
+ ```typescript
439
+ // client/api.ts
440
+ import { createPollyClient } from '@fairfox/polly/client'
441
+ import { $syncedState } from '@fairfox/polly'
442
+ import type { app } from '../server' // Import server type!
443
+
444
+ // Define client state
445
+ export const clientState = {
446
+ todos: $syncedState('todos', []),
447
+ user: $syncedState('user', null),
448
+ }
449
+
450
+ // Create type-safe API client (types inferred from server!)
451
+ export const api = createPollyClient<typeof app>('http://localhost:3000', {
452
+ state: clientState,
453
+ websocket: true, // Enable real-time updates
454
+ })
455
+ ```
456
+
457
+ ```typescript
458
+ // client/components/TodoList.tsx
459
+ import { useSignal } from '@preact/signals'
460
+ import { api, clientState } from '../api'
461
+
462
+ export function TodoList() {
463
+ const newTodo = useSignal('')
464
+
465
+ async function handleAdd() {
466
+ // Automatically handles:
467
+ // - Optimistic update if offline
468
+ // - Queue for retry
469
+ // - Execute client effect on success
470
+ // - Broadcast to other clients
471
+ await api.todos.post({ text: newTodo.value })
472
+ newTodo.value = ''
473
+ }
474
+
475
+ return (
476
+ <div>
477
+ {/* Connection status */}
478
+ <div>Status: {api.$polly.state.isOnline.value ? '🟢 Online' : '🔴 Offline'}</div>
479
+
480
+ {/* Queued requests indicator */}
481
+ {api.$polly.state.queuedRequests.value.length > 0 && (
482
+ <div>{api.$polly.state.queuedRequests.value.length} requests queued</div>
483
+ )}
484
+
485
+ {/* Todo list (automatically updates from state) */}
486
+ <ul>
487
+ {clientState.todos.value.map(todo => (
488
+ <li key={todo.id}>
489
+ <input
490
+ type="checkbox"
491
+ checked={todo.completed}
492
+ onChange={() => api.todos[todo.id].patch({ completed: !todo.completed })}
493
+ />
494
+ <span>{todo.text}</span>
495
+ <button onClick={() => api.todos[todo.id].delete()}>Delete</button>
496
+ </li>
497
+ ))}
498
+ </ul>
499
+
500
+ {/* Add new todo */}
501
+ <input
502
+ value={newTodo.value}
503
+ onInput={(e) => newTodo.value = e.currentTarget.value}
504
+ placeholder="What needs to be done?"
505
+ />
506
+ <button onClick={handleAdd}>Add</button>
507
+ </div>
508
+ )
509
+ }
510
+ ```
511
+
512
+ #### Key Benefits
513
+
514
+ 1. **Zero Type Duplication** - Eden infers client types from Elysia routes automatically
515
+ 2. **Distributed Systems Semantics** - Explicit offline, authorization, and effects configuration
516
+ 3. **Production-Ready** - Middleware is pass-through in production (minimal overhead)
517
+ 4. **Real-Time Updates** - WebSocket broadcast keeps all clients in sync
518
+ 5. **Formal Verification** - Generate TLA+ specs from middleware config to verify distributed properties
519
+
520
+ #### Production vs Development
521
+
522
+ **Development Mode:**
523
+ - Middleware adds metadata to responses for hot-reload and debugging
524
+ - Client effects serialized from server for live updates
525
+ - TLA+ generation enabled for verification
526
+
527
+ **Production Mode:**
528
+ - Middleware is minimal (authorization + broadcast only)
529
+ - Client effects are bundled at build time
530
+ - Zero serialization overhead
531
+
346
532
  ## Core Concepts
347
533
 
348
534
  ### State Primitives
@@ -5435,40 +5435,116 @@ class HandlerExtractor {
5435
5435
  project;
5436
5436
  typeGuardCache;
5437
5437
  relationshipExtractor;
5438
+ analyzedFiles;
5439
+ packageRoot;
5438
5440
  constructor(tsConfigPath) {
5439
5441
  this.project = new Project3({
5440
5442
  tsConfigFilePath: tsConfigPath
5441
5443
  });
5442
5444
  this.typeGuardCache = new WeakMap;
5443
5445
  this.relationshipExtractor = new RelationshipExtractor;
5446
+ this.analyzedFiles = new Set;
5447
+ this.packageRoot = this.findPackageRoot(tsConfigPath);
5448
+ }
5449
+ findPackageRoot(tsConfigPath) {
5450
+ let dir = tsConfigPath.substring(0, tsConfigPath.lastIndexOf("/"));
5451
+ while (dir.length > 1) {
5452
+ try {
5453
+ const packageJsonPath = `${dir}/package.json`;
5454
+ const file = Bun.file(packageJsonPath);
5455
+ if (file.size > 0) {
5456
+ return dir;
5457
+ }
5458
+ } catch {}
5459
+ const parentDir = dir.substring(0, dir.lastIndexOf("/"));
5460
+ if (parentDir === dir)
5461
+ break;
5462
+ dir = parentDir;
5463
+ }
5464
+ return tsConfigPath.substring(0, tsConfigPath.lastIndexOf("/"));
5444
5465
  }
5445
5466
  extractHandlers() {
5446
5467
  const handlers = [];
5447
5468
  const messageTypes = new Set;
5448
5469
  const invalidMessageTypes = new Set;
5449
5470
  const stateConstraints = [];
5450
- const sourceFiles = this.project.getSourceFiles();
5451
- this.debugLogSourceFiles(sourceFiles);
5452
- for (const sourceFile of sourceFiles) {
5453
- const fileHandlers = this.extractFromFile(sourceFile);
5454
- handlers.push(...fileHandlers);
5455
- this.categorizeHandlerMessageTypes(fileHandlers, messageTypes, invalidMessageTypes);
5456
- const fileConstraints = this.extractStateConstraintsFromFile(sourceFile);
5457
- stateConstraints.push(...fileConstraints);
5471
+ const allSourceFiles = this.project.getSourceFiles();
5472
+ const entryPoints = allSourceFiles.filter((f) => this.isWithinPackage(f.getFilePath()));
5473
+ this.debugLogSourceFiles(allSourceFiles, entryPoints);
5474
+ for (const entryPoint of entryPoints) {
5475
+ this.analyzeFileAndImports(entryPoint, handlers, messageTypes, invalidMessageTypes, stateConstraints);
5458
5476
  }
5459
5477
  this.debugLogExtractionResults(handlers.length, invalidMessageTypes.size);
5478
+ this.debugLogAnalysisStats(allSourceFiles.length, entryPoints.length);
5460
5479
  return {
5461
5480
  handlers,
5462
5481
  messageTypes,
5463
5482
  stateConstraints
5464
5483
  };
5465
5484
  }
5466
- debugLogSourceFiles(sourceFiles) {
5485
+ analyzeFileAndImports(sourceFile, handlers, messageTypes, invalidMessageTypes, stateConstraints) {
5486
+ const filePath = sourceFile.getFilePath();
5487
+ if (this.analyzedFiles.has(filePath)) {
5488
+ return;
5489
+ }
5490
+ this.analyzedFiles.add(filePath);
5491
+ if (process.env["POLLY_DEBUG"]) {
5492
+ console.log(`[DEBUG] Analyzing: ${filePath}`);
5493
+ }
5494
+ const fileHandlers = this.extractFromFile(sourceFile);
5495
+ handlers.push(...fileHandlers);
5496
+ this.categorizeHandlerMessageTypes(fileHandlers, messageTypes, invalidMessageTypes);
5497
+ const fileConstraints = this.extractStateConstraintsFromFile(sourceFile);
5498
+ stateConstraints.push(...fileConstraints);
5499
+ const importDeclarations = sourceFile.getImportDeclarations();
5500
+ for (const importDecl of importDeclarations) {
5501
+ const importedFile = importDecl.getModuleSpecifierSourceFile();
5502
+ if (importedFile) {
5503
+ const importedPath = importedFile.getFilePath();
5504
+ if (!this.isWithinPackage(importedPath)) {
5505
+ if (process.env["POLLY_DEBUG"]) {
5506
+ console.log(`[DEBUG] Skipping external import: ${importedPath}`);
5507
+ }
5508
+ continue;
5509
+ }
5510
+ this.analyzeFileAndImports(importedFile, handlers, messageTypes, invalidMessageTypes, stateConstraints);
5511
+ } else if (process.env["POLLY_DEBUG"]) {
5512
+ const specifier = importDecl.getModuleSpecifierValue();
5513
+ if (!specifier.startsWith("node:") && !this.isNodeModuleImport(specifier)) {
5514
+ console.log(`[DEBUG] Could not resolve import: ${specifier} in ${filePath}`);
5515
+ }
5516
+ }
5517
+ }
5518
+ }
5519
+ isWithinPackage(filePath) {
5520
+ if (!filePath.startsWith(this.packageRoot)) {
5521
+ return false;
5522
+ }
5523
+ if (filePath.includes("/node_modules/")) {
5524
+ return false;
5525
+ }
5526
+ return true;
5527
+ }
5528
+ isNodeModuleImport(specifier) {
5529
+ return !specifier.startsWith(".") && !specifier.startsWith("/");
5530
+ }
5531
+ debugLogAnalysisStats(totalSourceFiles, entryPointCount) {
5532
+ if (!process.env["POLLY_DEBUG"])
5533
+ return;
5534
+ console.log(`[DEBUG] Analysis Statistics:`);
5535
+ console.log(`[DEBUG] Package root: ${this.packageRoot}`);
5536
+ console.log(`[DEBUG] Source files from tsconfig: ${totalSourceFiles}`);
5537
+ console.log(`[DEBUG] Entry points (in package): ${entryPointCount}`);
5538
+ console.log(`[DEBUG] Files analyzed (including imports): ${this.analyzedFiles.size}`);
5539
+ console.log(`[DEBUG] Additional files discovered: ${this.analyzedFiles.size - entryPointCount}`);
5540
+ }
5541
+ debugLogSourceFiles(allSourceFiles, entryPoints) {
5467
5542
  if (!process.env["POLLY_DEBUG"])
5468
5543
  return;
5469
- console.log(`[DEBUG] Loaded ${sourceFiles.length} source files`);
5470
- if (sourceFiles.length <= 20) {
5471
- for (const sf of sourceFiles) {
5544
+ console.log(`[DEBUG] Loaded ${allSourceFiles.length} source files from tsconfig`);
5545
+ console.log(`[DEBUG] Filtered to ${entryPoints.length} entry points within package`);
5546
+ if (entryPoints.length <= 20) {
5547
+ for (const sf of entryPoints) {
5472
5548
  console.log(`[DEBUG] - ${sf.getFilePath()}`);
5473
5549
  }
5474
5550
  }
@@ -8810,6 +8886,7 @@ ${generateVerificationSection(context)}
8810
8886
  - **Performance**: How to optimize verification speed and state space exploration
8811
8887
  - **Debugging**: Interpreting counterexamples and fixing violations
8812
8888
  - **Configuration**: Understanding maxInFlight, bounds, and other verification parameters
8889
+ - **Elysia Integration**: Using Polly with Elysia/Bun servers for full-stack distributed systems verification
8813
8890
 
8814
8891
  # Important Notes
8815
8892
 
@@ -8822,6 +8899,149 @@ ${generateVerificationSection(context)}
8822
8899
  - Example: \`$constraints("loggedIn", { USER_LOGOUT: { requires: "state.loggedIn === true" } })\`
8823
8900
  - This eliminates duplication and creates a single source of truth for state invariants
8824
8901
  - Parser extracts constraints and adds them to all relevant message handlers automatically
8902
+ - **File Organization**: Constraints can be organized in separate files (e.g., specs/constraints.ts)
8903
+ - **Transitive Discovery**: The analyzer uses transitive import following to discover constraints
8904
+ - Files outside src/ are automatically found if imported from handler files
8905
+ - This enables clean separation of verification code from runtime code
8906
+
8907
+ # Elysia/Bun Integration
8908
+
8909
+ Polly now provides first-class support for Elysia (Bun-first web framework) with Eden type-safe client generation:
8910
+
8911
+ ## Server-Side Middleware (\`@fairfox/polly/elysia\`)
8912
+
8913
+ The \`polly()\` middleware adds distributed systems semantics to Elysia apps:
8914
+
8915
+ **Key Features:**
8916
+ - **State Management**: Define client and server state signals
8917
+ - **Client Effects**: Specify what should happen on the client after server operations
8918
+ - **Authorization**: Route-level authorization rules
8919
+ - **Offline Behavior**: Configure optimistic updates and queueing
8920
+ - **WebSocket Broadcast**: Real-time updates to connected clients
8921
+ - **TLA+ Generation**: Automatic formal specification generation from routes + config
8922
+
8923
+ **Example:**
8924
+ \`\`\`typescript
8925
+ import { Elysia, t } from 'elysia';
8926
+ import { polly } from '@fairfox/polly/elysia';
8927
+ import { $syncedState, $serverState } from '@fairfox/polly';
8928
+
8929
+ const app = new Elysia()
8930
+ .use(polly({
8931
+ state: {
8932
+ client: {
8933
+ todos: $syncedState('todos', []),
8934
+ user: $syncedState('user', null),
8935
+ },
8936
+ server: {
8937
+ db: $serverState('db', db),
8938
+ },
8939
+ },
8940
+ effects: {
8941
+ 'POST /todos': {
8942
+ client: ({ result, state }) => {
8943
+ state.client.todos.value = [...state.client.todos.value, result];
8944
+ },
8945
+ broadcast: true, // Send to all connected clients
8946
+ },
8947
+ },
8948
+ authorization: {
8949
+ 'POST /todos': ({ state }) => state.client.user.value !== null,
8950
+ },
8951
+ offline: {
8952
+ 'POST /todos': {
8953
+ queue: true,
8954
+ optimistic: (body) => ({ id: -Date.now(), ...body }),
8955
+ },
8956
+ },
8957
+ }))
8958
+ .post('/todos', handler, { body: t.Object({ text: t.String() }) });
8959
+ \`\`\`
8960
+
8961
+ **Route Pattern Matching:**
8962
+ - Exact: \`'POST /todos'\`
8963
+ - Params: \`'GET /todos/:id'\`
8964
+ - Wildcard: \`'/todos/*'\`
8965
+
8966
+ **Production Behavior:**
8967
+ - In development: Adds metadata to responses for hot-reload and debugging
8968
+ - In production: Pass-through (minimal overhead) - client effects are bundled at build time
8969
+ - Authorization and broadcasts work in both modes
8970
+
8971
+ ## Client-Side Wrapper (\`@fairfox/polly/client\`)
8972
+
8973
+ Enhances Eden treaty client with Polly features:
8974
+
8975
+ **Example:**
8976
+ \`\`\`typescript
8977
+ import { createPollyClient } from '@fairfox/polly/client';
8978
+ import { $syncedState } from '@fairfox/polly';
8979
+ import type { app } from './server';
8980
+
8981
+ const clientState = {
8982
+ todos: $syncedState('todos', []),
8983
+ user: $syncedState('user', null),
8984
+ };
8985
+
8986
+ export const api = createPollyClient<typeof app>('http://localhost:3000', {
8987
+ state: clientState,
8988
+ websocket: true, // Enable real-time updates
8989
+ });
8990
+
8991
+ // Use it (types are automatically inferred from server!)
8992
+ await api.todos.post({ text: 'Buy milk' });
8993
+
8994
+ // Access Polly features
8995
+ console.log(api.$polly.state.isOnline.value); // true/false
8996
+ console.log(api.$polly.state.queuedRequests.value); // Queued requests
8997
+ api.$polly.sync(); // Manually sync queued requests
8998
+ \`\`\`
8999
+
9000
+ **Key Features:**
9001
+ - Offline queueing with automatic retry
9002
+ - WebSocket connection for real-time updates
9003
+ - Lamport clock synchronization
9004
+ - Type inference from server via Eden
9005
+
9006
+ ## Why This Matters for Distributed Systems
9007
+
9008
+ SPAs/PWAs are distributed systems facing classic problems:
9009
+ - **CAP theorem**: Must choose consistency vs availability during partitions
9010
+ - **Network unreliability**: The first fallacy of distributed computing
9011
+ - **Cache invalidation**: "One of the two hard things in computer science"
9012
+ - **Eventual consistency**: State sync across client/server
9013
+ - **Conflict resolution**: When multiple devices edit offline
9014
+
9015
+ The Elysia integration addresses this by:
9016
+ 1. Making distributed concerns explicit (offline, authorization, effects)
9017
+ 2. Leveraging Eden for zero-duplication type safety
9018
+ 3. Supporting verification via TLA+ generation from middleware config
9019
+ 4. Providing WebSocket broadcast for real-time consistency
9020
+
9021
+ ## Architecture Pattern
9022
+
9023
+ \`\`\`
9024
+ Browser (Client)
9025
+ ├── Eden Treaty Client (types from Elysia)
9026
+ ├── Polly Wrapper (offline, sync, WebSocket)
9027
+ └── Client State ($syncedState)
9028
+
9029
+ │ HTTP / WebSocket
9030
+
9031
+ Server (Elysia + Bun)
9032
+ ├── Elysia Routes (normal routes)
9033
+ ├── Polly Middleware (effects, auth, broadcast)
9034
+ └── Server State ($serverState)
9035
+ \`\`\`
9036
+
9037
+ ## Best Practices
9038
+
9039
+ 1. **Separate Elysia is the contract** - Don't define types twice. Elysia routes define the API, Eden generates client types.
9040
+ 2. **Effects describe client behavior** - Keep effects pure and deterministic.
9041
+ 3. **Authorization at route level** - Centralize security rules in middleware config.
9042
+ 4. **Queue selectively** - Only queue idempotent operations when offline.
9043
+ 5. **Broadcast with filters** - Use broadcastFilter to target specific clients.
9044
+ 6. **Generate TLA+ for verification** - Enable tlaGeneration in dev to verify distributed properties.
8825
9045
 
8826
9046
  Begin by understanding their question and providing a clear, precise answer based on their project context.`;
8827
9047
  }
@@ -8968,6 +9188,10 @@ to reduce verification time while maintaining or improving verification precisio
8968
9188
  and available in the current version of Polly. You can recommend any of these optimizations with
8969
9189
  confidence that they will work when users apply them to their configuration.
8970
9190
 
9191
+ **Code Organization**: The analyzer uses transitive import following to discover all reachable code.
9192
+ Constraints and type guards can be organized in separate files (e.g., specs/constraints.ts) and will
9193
+ be automatically discovered via imports. Files outside src/ are fully supported.
9194
+
8971
9195
  # Communication Style
8972
9196
 
8973
9197
  - Direct and precise - no fluff
@@ -9284,4 +9508,4 @@ Goodbye!`);
9284
9508
  }
9285
9509
  main();
9286
9510
 
9287
- //# debugId=8BD8EAFF3D45D43B64756E2164756E21
9511
+ //# debugId=CDFF2E3A99C1DA5C64756E2164756E21