sandly 0.4.0 → 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.
Files changed (4) hide show
  1. package/README.md +188 -43
  2. package/dist/index.d.ts +161 -137
  3. package/dist/index.js +131 -169
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -74,8 +74,8 @@ class UserRepository extends Tag.Service('UserRepository') {
74
74
  // Register services with their factories
75
75
  const container = Container.empty()
76
76
  .register(Database, {
77
- factory: () => new Database(),
78
- finalizer: (db) => db.close(), // Cleanup when container is destroyed
77
+ create: () => new Database(),
78
+ cleanup: (db) => db.close(), // Cleanup when container is destroyed
79
79
  })
80
80
  .register(
81
81
  UserRepository,
@@ -106,8 +106,8 @@ import { layer, autoService, Container } from 'sandly';
106
106
  // Layer that provides Database
107
107
  const databaseLayer = layer<never, typeof Database>((container) =>
108
108
  container.register(Database, {
109
- factory: () => new Database(),
110
- finalizer: (db) => db.close(),
109
+ create: () => new Database(),
110
+ cleanup: (db) => db.close(),
111
111
  })
112
112
  );
113
113
 
@@ -330,12 +330,12 @@ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
330
330
  }
331
331
 
332
332
  const container = Container.empty().register(DatabaseConnection, {
333
- factory: async () => {
333
+ create: async () => {
334
334
  const db = new DatabaseConnection();
335
335
  await db.connect(); // Async initialization
336
336
  return db;
337
337
  },
338
- finalizer: async (db) => {
338
+ cleanup: async (db) => {
339
339
  await db.disconnect(); // Async cleanup
340
340
  },
341
341
  });
@@ -485,7 +485,7 @@ Before diving into detailed usage, let's understand the four main building block
485
485
 
486
486
  ### Tags
487
487
 
488
- Tags are unique identifiers for dependencies. They come in two flavors:
488
+ Tags are unique tokens that represent dependencies and serve as a way to reference them in the container. They come in two flavors:
489
489
 
490
490
  **ServiceTag** - For class-based dependencies. Created by extending `Tag.Service()`:
491
491
 
@@ -507,7 +507,7 @@ const ApiKeyTag = Tag.of('ApiKey')<string>();
507
507
  const ConfigTag = Tag.of('Config')<{ port: number }>();
508
508
  ```
509
509
 
510
- ValueTags separate the identifier from the value type. The string identifier must be unique, which is why configuration values are the main use case (be careful with generic names like `'port'` or `'config'` - prefer namespaced identifiers like `'app.port'`).
510
+ ValueTags separate the identifier from the value type. The string identifier should be unique in order to avoid collisions in TypeScript type error reporting. The main use-case for ValueTags is for injecting configuration values. Be careful with generic names like `'ApiKey'` or `'Config'` - prefer specific identifiers like `'ThirdPartyApiKey'` or `'HttpClientConfig'`.
511
511
 
512
512
  ### Container
513
513
 
@@ -712,7 +712,7 @@ console.log(logger1 === logger2); // true
712
712
 
713
713
  #### Finalizers for Cleanup
714
714
 
715
- Register finalizers to clean up resources when the container is destroyed:
715
+ Register finalizers to clean up resources when the container is destroyed. They receive the created instance and should perform any necessary cleanup (closing connections, releasing resources, etc.):
716
716
 
717
717
  ```typescript
718
718
  class DatabaseConnection extends Tag.Service('DatabaseConnection') {
@@ -735,12 +735,14 @@ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
735
735
  }
736
736
 
737
737
  const container = Container.empty().register(DatabaseConnection, {
738
- factory: async () => {
738
+ // Factory
739
+ create: async () => {
739
740
  const db = new DatabaseConnection();
740
741
  await db.connect();
741
742
  return db;
742
743
  },
743
- finalizer: async (db) => {
744
+ // Finalizer
745
+ cleanup: async (db) => {
744
746
  await db.disconnect();
745
747
  },
746
748
  });
@@ -754,25 +756,103 @@ await container.destroy();
754
756
  // Output: "Disconnected"
755
757
  ```
756
758
 
759
+ You can also implement `DependencyLifecycle` as a class for better organization and reuse:
760
+
761
+ ```typescript
762
+ import {
763
+ Container,
764
+ Tag,
765
+ type DependencyLifecycle,
766
+ type ResolutionContext,
767
+ } from 'sandly';
768
+
769
+ class Logger extends Tag.Service('Logger') {
770
+ log(message: string) {
771
+ console.log(message);
772
+ }
773
+ }
774
+
775
+ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
776
+ constructor(
777
+ private logger: Logger,
778
+ private url: string
779
+ ) {
780
+ super();
781
+ }
782
+ async connect() {
783
+ this.logger.log('Connected');
784
+ }
785
+ async disconnect() {
786
+ this.logger.log('Disconnected');
787
+ }
788
+ }
789
+
790
+ class DatabaseLifecycle
791
+ implements DependencyLifecycle<DatabaseConnection, typeof Logger>
792
+ {
793
+ constructor(private url: string) {}
794
+
795
+ async create(
796
+ ctx: ResolutionContext<typeof Logger>
797
+ ): Promise<DatabaseConnection> {
798
+ const logger = await ctx.resolve(Logger);
799
+ const db = new DatabaseConnection(logger, this.url);
800
+ await db.connect();
801
+ return db;
802
+ }
803
+
804
+ async cleanup(db: DatabaseConnection): Promise<void> {
805
+ await db.disconnect();
806
+ }
807
+ }
808
+
809
+ const container = Container.empty()
810
+ .register(Logger, () => new Logger())
811
+ .register(
812
+ DatabaseConnection,
813
+ new DatabaseLifecycle('postgresql://localhost:5432')
814
+ );
815
+ ```
816
+
817
+ The `cleanup` method is optional, so you can implement classes with only a `create` method:
818
+
819
+ ```typescript
820
+ import { Container, Tag, type DependencyLifecycle } from 'sandly';
821
+
822
+ class SimpleService extends Tag.Service('SimpleService') {}
823
+
824
+ class SimpleServiceFactory
825
+ implements DependencyLifecycle<SimpleService, never>
826
+ {
827
+ create(): SimpleService {
828
+ return new SimpleService();
829
+ }
830
+ // cleanup is optional
831
+ }
832
+
833
+ const container = Container.empty().register(
834
+ SimpleService,
835
+ new SimpleServiceFactory()
836
+ );
837
+ ```
838
+
757
839
  All finalizers run concurrently when you call `destroy()`:
758
840
 
759
841
  ```typescript
760
842
  const container = Container.empty()
761
843
  .register(Database, {
762
- factory: () => new Database(),
763
- finalizer: (db) => db.close(),
844
+ create: () => new Database(),
845
+ cleanup: (db) => db.close(),
764
846
  })
765
847
  .register(Cache, {
766
- factory: () => new Cache(),
767
- finalizer: (cache) => cache.clear(),
848
+ create: () => new Cache(),
849
+ cleanup: (cache) => cache.clear(),
768
850
  });
769
851
 
770
852
  // Both finalizers run in parallel
771
853
  await container.destroy();
772
854
  ```
773
855
 
774
- If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures.
775
-
776
856
  #### Overriding Registrations
777
857
 
778
858
  You can override a registration before it's instantiated:
@@ -934,6 +1014,51 @@ try {
934
1014
  }
935
1015
  ```
936
1016
 
1017
+ #### Finalization Errors
1018
+
1019
+ If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
1020
+
1021
+ ```typescript
1022
+ class Database extends Tag.Service('Database') {
1023
+ async close() {
1024
+ throw new Error('Database close failed');
1025
+ }
1026
+ }
1027
+
1028
+ class Cache extends Tag.Service('Cache') {
1029
+ async clear() {
1030
+ throw new Error('Cache clear failed');
1031
+ }
1032
+ }
1033
+
1034
+ const container = Container.empty()
1035
+ .register(Database, {
1036
+ create: () => new Database(),
1037
+ cleanup: async (db) => db.close(),
1038
+ })
1039
+ .register(Cache, {
1040
+ create: () => new Cache(),
1041
+ cleanup: async (cache) => cache.clear(),
1042
+ });
1043
+
1044
+ await container.resolve(Database);
1045
+ await container.resolve(Cache);
1046
+
1047
+ try {
1048
+ await container.destroy();
1049
+ } catch (error) {
1050
+ if (error instanceof DependencyFinalizationError) {
1051
+ // Get all original errors that caused the finalization failure
1052
+ const rootCauses = error.getRootCauses();
1053
+ console.error('Finalization failures:', rootCauses);
1054
+ // [
1055
+ // Error: Database close failed,
1056
+ // Error: Cache clear failed
1057
+ // ]
1058
+ }
1059
+ }
1060
+ ```
1061
+
937
1062
  ### Type Safety in Action
938
1063
 
939
1064
  The container's type parameter tracks all registered dependencies:
@@ -995,18 +1120,6 @@ container.register(Logger, () => new Logger());
995
1120
  // TypeScript doesn't track these registrations
996
1121
  ```
997
1122
 
998
- **Use namespaced identifiers for ValueTags** - Prevents collisions:
999
-
1000
- ```typescript
1001
- // ❌ Generic names can collide
1002
- const Port = Tag.of('port')<number>();
1003
- const Timeout = Tag.of('timeout')<number>();
1004
-
1005
- // ✅ Namespaced identifiers
1006
- const ServerPort = Tag.of('server.port')<number>();
1007
- const HttpTimeout = Tag.of('http.timeout')<number>();
1008
- ```
1009
-
1010
1123
  **Prefer layers for multiple dependencies** - Once you have larger numbers of services and more complex dependency graphs, layers become cleaner. See the next section for more details.
1011
1124
 
1012
1125
  **Handle cleanup errors** - Finalizers can fail:
@@ -1022,6 +1135,39 @@ try {
1022
1135
  }
1023
1136
  ```
1024
1137
 
1138
+ **Avoid resolving during registration if possible** - Once you resolve a dependency, the container will cache it and you won't be able to override the registration. This might become problematic in case you're composing layers and multiple layers reference the same layer in their provisions (see more on layers below). It's better to keep registration and resolution separate:
1139
+
1140
+ ```typescript
1141
+ // ❌ Bad - resolving during setup creates timing issues
1142
+ const container = Container.empty().register(Logger, () => new Logger());
1143
+
1144
+ const logger = await container.resolve(Logger); // During setup!
1145
+
1146
+ container.register(Database, () => new Database());
1147
+
1148
+ // ✅ Good - register everything first, then resolve
1149
+ const container = Container.empty()
1150
+ .register(Logger, () => new Logger())
1151
+ .register(Database, () => new Database());
1152
+
1153
+ // Now use services
1154
+ const logger = await container.resolve(Logger);
1155
+ ```
1156
+
1157
+ However, it's perfectly fine to resolve and even use dependencies inside another dependency factory function.
1158
+
1159
+ ```typescript
1160
+ // ✅ Also good - resolve dependency inside factory function during the registration
1161
+ const container = Container.empty()
1162
+ .register(Logger, () => new Logger())
1163
+ .register(Database, (ctx) => {
1164
+ const db = new Database();
1165
+ const logger = await ctx.resolve(Logger);
1166
+ logger.log('Database created successfully');
1167
+ return db;
1168
+ });
1169
+ ```
1170
+
1025
1171
  ## Working with Layers
1026
1172
 
1027
1173
  Layers are the recommended approach for organizing dependencies in larger applications. While direct container registration works well for small projects, layers provide better code organization, reusability, and developer experience as your application grows.
@@ -1410,12 +1556,12 @@ const userRepositoryLayer = service(UserRepository, async (ctx) => {
1410
1556
 
1411
1557
  // With finalizer
1412
1558
  const databaseLayer = service(Database, {
1413
- factory: async () => {
1559
+ create: async () => {
1414
1560
  const db = new Database();
1415
1561
  await db.connect();
1416
1562
  return db;
1417
1563
  },
1418
- finalizer: (db) => db.disconnect(),
1564
+ cleanup: (db) => db.disconnect(),
1419
1565
  });
1420
1566
  ```
1421
1567
 
@@ -1474,12 +1620,12 @@ const apiClientLayer = autoService(ApiClient, [
1474
1620
 
1475
1621
  **Important**: ValueTag dependencies in constructors must be annotated with `Inject<typeof YourTag>`. This preserves type information for `service()` and `autoService()` to infer the dependency. Without `Inject<>`, TypeScript sees it as a regular value and `service()` and `autoService()` won't know to resolve it from the container.
1476
1622
 
1477
- With finalizer:
1623
+ With cleanup:
1478
1624
 
1479
1625
  ```typescript
1480
1626
  const databaseLayer = autoService(Database, {
1481
1627
  dependencies: ['postgresql://localhost:5432/mydb'],
1482
- finalizer: (db) => db.disconnect(),
1628
+ cleanup: (db) => db.disconnect(),
1483
1629
  });
1484
1630
  ```
1485
1631
 
@@ -1921,13 +2067,13 @@ But use `service()` when you need custom logic:
1921
2067
  ```typescript
1922
2068
  // ✅ Good - custom initialization logic
1923
2069
  const databaseLayer = service(Database, {
1924
- factory: async () => {
2070
+ create: async () => {
1925
2071
  const db = new Database();
1926
2072
  await db.connect();
1927
2073
  await db.runMigrations();
1928
2074
  return db;
1929
2075
  },
1930
- finalizer: (db) => db.disconnect(),
2076
+ cleanup: (db) => db.disconnect(),
1931
2077
  });
1932
2078
  ```
1933
2079
 
@@ -2277,23 +2423,23 @@ When a scope is destroyed, finalizers run in this order:
2277
2423
 
2278
2424
  ```typescript
2279
2425
  const appScope = ScopedContainer.empty('app').register(Database, {
2280
- factory: () => new Database(),
2281
- finalizer: (db) => {
2426
+ create: () => new Database(),
2427
+ cleanup: (db) => {
2282
2428
  console.log('Closing database');
2283
2429
  return db.close();
2284
2430
  },
2285
2431
  });
2286
2432
 
2287
2433
  const request1 = appScope.child('request-1').register(RequestContext, {
2288
- factory: () => new RequestContext('req-1'),
2289
- finalizer: (ctx) => {
2434
+ create: () => new RequestContext('req-1'),
2435
+ cleanup: (ctx) => {
2290
2436
  console.log('Cleaning up request-1');
2291
2437
  },
2292
2438
  });
2293
2439
 
2294
2440
  const request2 = appScope.child('request-2').register(RequestContext, {
2295
- factory: () => new RequestContext('req-2'),
2296
- finalizer: (ctx) => {
2441
+ create: () => new RequestContext('req-2'),
2442
+ cleanup: (ctx) => {
2297
2443
  console.log('Cleaning up request-2');
2298
2444
  },
2299
2445
  });
@@ -2565,8 +2711,7 @@ const service = await container.resolve(UserService); // Type-safe
2565
2711
  | -------------------------- | ------- | ---------- | --------------------- | -------- | --------- |
2566
2712
  | Compile-time type safety | ✅ Full | ❌ None | ⚠️ Partial | ❌ None | ✅ Full |
2567
2713
  | No experimental decorators | ✅ | ❌ | ❌ | ❌ | ✅ |
2568
- | Async factories | ✅ | ✅ | ❌ | ❌ | ✅ |
2569
- | Async finalizers | ✅ | ✅ | ❌ | ❌ | ✅ |
2714
+ | Async lifecycle methods | ✅ | ✅ | ❌ | ❌ | ✅ |
2570
2715
  | Framework-agnostic | ✅ | ❌ | ✅ | ✅ | ✅ |
2571
2716
  | Learning curve | Low | Medium | Medium | Low | Very High |
2572
2717
  | Bundle size | Small | Large | Medium | Small | Large |