locality-idb 1.2.1 → 1.3.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.
package/README.md CHANGED
@@ -41,6 +41,8 @@
41
41
  - [Select/Query Records](#selectquery-records)
42
42
  - [Update Records](#update-records)
43
43
  - [Delete Records](#delete-records)
44
+ - [Transactions](#transactions)
45
+ - [Export Database](#export-database)
44
46
  - [API Reference](#-api-reference)
45
47
  - [Locality Class](#locality-class)
46
48
  - [Schema Functions](#schema-functions)
@@ -64,6 +66,8 @@
64
66
  - 🛠️ **Rich Column Types**: Support for various data types including custom types
65
67
  - ✅ **Built-in Validation**: Automatic data type validation for built-in column types during insert and update operations
66
68
  - 🔧 **Custom Validators**: Define custom validation logic for columns to enforce complex rules
69
+ - 🔒 **Atomic Transactions**: Execute multiple operations across tables with automatic rollback on failure
70
+ - 📤 **Database Export**: Export database data as JSON for backup, migration, or debugging
67
71
 
68
72
  ---
69
73
 
@@ -205,6 +209,8 @@ Locality IDB supports a wide range of column types:
205
209
  | `text()` / `string()` | Text strings | `column.text()` |
206
210
  | `char(length?)` | Fixed-length string | `column.char(10)` |
207
211
  | `varchar(length?)` | Variable-length string | `column.varchar(255)` |
212
+ | `email()` | Email strings | `column.email()` |
213
+ | `url()` | URL strings | `column.url()` |
208
214
  | `bool()` / `boolean()` | Boolean values | `column.bool()` |
209
215
  | `date()` | Date objects | `column.date()` |
210
216
  | `timestamp()` | ISO 8601 timestamps ([auto-generated](#utility-functions)) | `column.timestamp()` |
@@ -251,7 +257,7 @@ const productId: ProductId = 2 as ProductId;
251
257
  // userId = productId; // ❌ Type error!
252
258
  ```
253
259
 
254
- ##### String Types (`text`, `string`, `char`, `varchar`)
260
+ ##### String Types (`text`, `string`, `char`, `varchar`, `email`, `url`)
255
261
 
256
262
  ```typescript
257
263
  // Literal unions for enum-like behavior
@@ -277,8 +283,40 @@ const profileSchema = defineSchema({
277
283
  website: column.varchar<URL>(500).optional(),
278
284
  },
279
285
  });
286
+
287
+ // or use specialized `email` & `url` methods + types with built-in validation
288
+ const advancedProfileSchema = defineSchema({
289
+ profiles: {
290
+ id: column.int().pk().auto(),
291
+ email: column.email().unique(), // Validates emails
292
+ website: column.url().optional(), // Validates URLs (internally uses URL constructor)
293
+ },
294
+ });
295
+ ```
296
+
297
+ #### Auto-generated Types (`uuid`, `timestamp`)
298
+
299
+ ```typescript
300
+ const schema = defineSchema({
301
+ sessions: {
302
+ id: column.uuid().pk(), // Auto-generated UUID v4
303
+ idWithDefault: column.uuid().pk().default(uuid({ version: 'v6' })), // Replace auto-generated UUID v4
304
+ createdAt: column.timestamp(), // Auto-generated timestamp
305
+ defaultTs: column.timestamp().default(getTimestamp()), // Auto-generated timestamp with default using utility built-in function
306
+ customTs: column.timestamp().default(new Chronos().toLocalISOString() as Timestamp), // Default timestamp with custom format
307
+ },
308
+ });
280
309
  ```
281
310
 
311
+ > **Note:**
312
+ >
313
+ > - Auto-generated values can be overridden by providing explicit values during insert.
314
+ > - Use the `default()` modifier to set custom default values instead of auto-generated ones.
315
+ > - Auto-generated values are generated at runtime during insert operations.
316
+ > - Type extensions for `uuid` and `timestamp` are not applicable since they are already typed.
317
+ > - For custom UUID versions, use [`uuid`](https://toolbox.nazmul-nhb.dev/docs/utilities/hash/uuid) utility from [`nhb-toolbox`](https://www.npmjs.com/package/nhb-toolbox).
318
+ > - For custom timestamp formats, use date libraries like [`Chronos`](https://toolbox.nazmul-nhb.dev/docs/classes/Chronos) (from [`nhb-toolbox`](https://www.npmjs.com/package/nhb-toolbox)) or [`date-fns`](https://www.npmjs.com/package/date-fns) to generate ISO 8601 strings.
319
+
282
320
  ##### Boolean Types (`bool`, `boolean`)
283
321
 
284
322
  ```typescript
@@ -688,6 +726,163 @@ await db
688
726
  .run();
689
727
  ```
690
728
 
729
+ ### Transactions
730
+
731
+ Transactions enable you to perform multiple operations across multiple tables atomically. All operations in a transaction either succeed together or fail together, ensuring data consistency.
732
+
733
+ #### Basic Transaction
734
+
735
+ ```typescript
736
+ // Create a user and their first post atomically
737
+ await db.transaction(['users', 'posts'], async (ctx) => {
738
+ const newUser = await ctx
739
+ .insert('users')
740
+ .values({ name: 'John Doe', email: 'john@example.com' })
741
+ .run();
742
+
743
+ await ctx
744
+ .insert('posts')
745
+ .values({
746
+ userId: newUser.id,
747
+ title: 'My First Post',
748
+ content: 'Hello World!',
749
+ })
750
+ .run();
751
+ });
752
+ ```
753
+
754
+ #### Transaction with Multiple Operations
755
+
756
+ ```typescript
757
+ // Transfer data between tables atomically
758
+ await db.transaction(['users', 'posts', 'comments'], async (ctx) => {
759
+ // Update user
760
+ await ctx
761
+ .update('users')
762
+ .set({ isActive: true })
763
+ .where((user) => user.id === 1)
764
+ .run();
765
+
766
+ // Create post
767
+ const post = await ctx
768
+ .insert('posts')
769
+ .values({ userId: 1, title: 'New Post', content: 'Content' })
770
+ .run();
771
+
772
+ // Add comment
773
+ await ctx
774
+ .insert('comments')
775
+ .values({ postId: post.id, userId: 1, text: 'First comment!' })
776
+ .run();
777
+
778
+ // Query within transaction
779
+ const userPosts = await ctx
780
+ .from('posts')
781
+ .where((p) => p.userId === 1)
782
+ .findAll();
783
+
784
+ console.log(`User now has ${userPosts.length} posts`);
785
+ });
786
+ ```
787
+
788
+ #### Automatic Rollback
789
+
790
+ ```typescript
791
+ try {
792
+ await db.transaction(['users', 'posts'], async (ctx) => {
793
+ const user = await ctx
794
+ .insert('users')
795
+ .values({ name: 'Alice', email: 'alice@example.com' })
796
+ .run();
797
+
798
+ // This will cause the entire transaction to rollback
799
+ throw new Error('Something went wrong!');
800
+
801
+ // This never executes
802
+ await ctx
803
+ .insert('posts')
804
+ .values({ userId: user.id, title: 'Post' })
805
+ .run();
806
+ });
807
+ } catch (error) {
808
+ console.error('Transaction failed:', error);
809
+ // No data was inserted - transaction was rolled back
810
+ }
811
+ ```
812
+
813
+ > **Note:**
814
+ >
815
+ > - Transactions guarantee atomicity: all operations succeed or all fail.
816
+ > - If any operation fails or an error is thrown, the entire transaction is automatically rolled back.
817
+ > - Transaction context (`ctx`) provides `insert()`, `update()`, `delete()`, and `from()` methods.
818
+ > - All operations must be performed on tables specified in the transaction.
819
+
820
+ ### Export Database
821
+
822
+ Export your database data as JSON for backup, migration, or debugging purposes. The export includes metadata and table data, and automatically triggers a browser download.
823
+
824
+ #### Export All Tables
825
+
826
+ ```typescript
827
+ // Export entire database with pretty-printed JSON
828
+ await db.export();
829
+ // Downloads: my-database-2026-02-04T10-30-45-123Z.json
830
+ ```
831
+
832
+ #### Export Specific Tables
833
+
834
+ ```typescript
835
+ // Export only users and posts tables
836
+ await db.export({
837
+ tables: ['users', 'posts'],
838
+ filename: 'users-posts-backup.json',
839
+ });
840
+ ```
841
+
842
+ #### Export with Custom Options
843
+
844
+ ```typescript
845
+ // Export with custom configuration
846
+ await db.export({
847
+ tables: ['users'], // Optional: specific tables
848
+ filename: 'users-export.json', // Optional: custom filename
849
+ pretty: false, // Optional: compact JSON (default: true)
850
+ includeMetadata: true, // Optional: include metadata (default: true)
851
+ });
852
+ ```
853
+
854
+ #### Export Data Structure
855
+
856
+ The exported JSON file contains:
857
+
858
+ ```json
859
+ {
860
+ "metadata": {
861
+ "dbName": "my-database",
862
+ "version": 1,
863
+ "exportedAt": "2026-02-04T10:30:45.123Z",
864
+ "tables": ["users", "posts"]
865
+ },
866
+ "data": {
867
+ "users": [
868
+ { "id": 1, "name": "Alice", "email": "alice@example.com" },
869
+ { "id": 2, "name": "Bob", "email": "bob@example.com" }
870
+ ],
871
+ "posts": [
872
+ { "id": 1, "userId": 1, "title": "First Post", "content": "..." }
873
+ ]
874
+ }
875
+ }
876
+ ```
877
+
878
+ > **Note:**
879
+ >
880
+ > - Exported files are automatically downloaded in the browser.
881
+ > - Default filename format: `{dbName}-{timestamp}.json`
882
+ > - Metadata is included by default but can be disabled.
883
+ > - Use `pretty: true` (default) for human-readable JSON.
884
+ > - Use `pretty: false` for compact JSON (smaller file size).
885
+
691
886
  ---
692
887
 
693
888
  ## 📚 API Reference
@@ -716,6 +911,58 @@ const db = new Locality({
716
911
  });
717
912
  ```
718
913
 
914
+ #### Properties
915
+
916
+ ##### `version: number` (getter)
917
+
918
+ Gets the current database version.
919
+
920
+ **Returns:** The database version number
921
+
922
+ **Example:**
923
+
924
+ ```typescript
925
+ const db = new Locality({
926
+ dbName: 'my-database',
927
+ version: 2,
928
+ schema: mySchema,
929
+ });
930
+
931
+ await db.ready(); // (optional) for extra safety
932
+ console.log(db.version); // 2
933
+ ```
934
+
935
+ ##### `tableList: string[]` (getter)
936
+
937
+ Gets all table (store) names in the current database.
938
+
939
+ **Returns:** Array of table names
940
+
941
+ **Example:**
942
+
943
+ ```typescript
944
+ const tables = db.tableList;
945
+ console.log(tables); // ['users', 'posts', 'comments']
946
+ ```
947
+
948
+ ##### `dbList: Promise<IDBDatabaseInfo[]>` (getter)
949
+
950
+ Gets the list of all existing IndexedDB databases in the current origin.
951
+
952
+ **Returns:** Array of database information objects containing name and version
953
+
954
+ **Example:**
955
+
956
+ ```typescript
957
+ const databases = await db.dbList;
958
+ console.log(databases);
959
+ // [{ name: 'my-database', version: 1 }, { name: 'other-db', version: 2 }]
960
+ ```
961
+
962
+ > This is an instance method that calls the static [`Locality.getDatabaseList()`](#localitygetdatabaselist-promiseidbdatabaseinfo) internally.
963
+
964
+ ---
965
+
719
966
  #### Methods
720
967
 
721
968
  ##### `ready(): Promise<void>`
@@ -827,6 +1074,162 @@ const allUsers = await db.from('users').findAll();
827
1074
  console.log(allUsers);
828
1075
  ```
829
1076
 
1077
+ #### `transaction<Tables>(tables: Tables[], callback: TransactionCallback): Promise<void>`
1078
+
1079
+ Executes multiple database operations across multiple tables in a single atomic transaction.
1080
+
1081
+ **Parameters:**
1082
+
1083
+ - `tables`: Array of table names to include in the transaction
1084
+ - `callback`: Async function that receives a transaction context and performs operations
1085
+
1086
+ **Returns:** Promise that resolves when the transaction completes successfully
1087
+
1088
+ **Transaction Context Methods:**
1089
+
1090
+ - `ctx.insert(table)`: Insert records within the transaction
1091
+ - `ctx.update(table)`: Update records within the transaction
1092
+ - `ctx.delete(table)`: Delete records within the transaction
1093
+ - `ctx.from(table)`: Query records within the transaction
1094
+
1095
+ **Example:**
1096
+
1097
+ ```typescript
1098
+ // Create user and post atomically
1099
+ await db.transaction(['users', 'posts'], async (ctx) => {
1100
+ const user = await ctx
1101
+ .insert('users')
1102
+ .values({ name: 'Alice', email: 'alice@example.com' })
1103
+ .run();
1104
+
1105
+ await ctx
1106
+ .insert('posts')
1107
+ .values({ userId: user.id, title: 'First Post', content: 'Hello!' })
1108
+ .run();
1109
+ });
1110
+ ```
1111
+
1112
+ > **Important:**
1113
+ >
1114
+ > - All operations succeed or all fail (atomicity).
1115
+ > - If any operation fails or an error is thrown, the entire transaction is rolled back.
1116
+ > - Only tables specified in the `tables` array can be accessed within the transaction.
1117
+ > - Transactions use IndexedDB's native transaction mechanism.
1118
+
1119
+ #### `export(options?: ExportOptions): Promise<void>`
1120
+
1121
+ Exports database data as a JSON file and triggers a browser download.
1122
+
1123
+ **Parameters:**
1124
+
1125
+ - `options`: Optional export configuration
1126
+ - `options.tables`: Array of table names to export (default: all tables)
1127
+ - `options.filename`: Custom filename (default: `{dbName}-{timestamp}.json`)
1128
+ - `options.pretty`: Enable pretty-printed JSON (default: `true`)
1129
+ - `options.includeMetadata`: Include export metadata (default: `true`)
1130
+
1131
+ **Returns:** Promise that resolves when the export completes
1132
+
1133
+ **Example:**
1134
+
1135
+ ```typescript
1136
+ // Export all tables with default settings
1137
+ await db.export();
1138
+
1139
+ // Export specific tables with custom filename
1140
+ await db.export({
1141
+ tables: ['users', 'posts'],
1142
+ filename: 'backup-2026-02-04.json',
1143
+ pretty: true,
1144
+ });
1145
+
1146
+ // Export without metadata in compact format
1147
+ await db.export({
1148
+ pretty: false,
1149
+ includeMetadata: false,
1150
+ });
1151
+ ```
1152
+
1153
+ **Exported JSON Structure:**
1154
+
1155
+ ```typescript
1156
+ {
1157
+ metadata?: { // Optional (when includeMetadata = true)
1158
+ dbName: string;
1159
+ version: number;
1160
+ exportedAt: string; // ISO 8601 timestamp
1161
+ tables: string[];
1162
+ };
1163
+ data: {
1164
+ [tableName: string]: Array<Record<string, any>>;
1165
+ };
1166
+ }
1167
+ ```
1168
+
1169
+ > **Note:**
1170
+ >
1171
+ > - Automatically triggers a file download in the browser.
1172
+ > - Exported data includes all records from specified tables.
1173
+ > - Use for backup, debugging, or data migration.
1174
+ > - File download works in browser environments only.
1175
+
1176
+ ---
1177
+
1178
+ #### Static Methods
1179
+
1180
+ ##### `Locality.getDatabaseList(): Promise<IDBDatabaseInfo[]>`
1181
+
1182
+ Gets the list of all existing IndexedDB databases in the current origin (static method).
1183
+
1184
+ **Returns:** Array of database information objects containing name and version
1185
+
1186
+ **Example:**
1187
+
1188
+ ```typescript
1189
+ import { Locality } from 'locality-idb';
1190
+
1191
+ const databases = await Locality.getDatabaseList();
1192
+ console.log(databases);
1193
+ // [{ name: 'app-db', version: 1 }, { name: 'cache-db', version: 2 }]
1194
+ ```
1195
+
1196
+ > **Note:**
1197
+ >
1198
+ > - This method requires IndexedDB support in the browser.
1199
+ > - Returns an empty array if the browser doesn't support `indexedDB.databases()`.
1200
+ > - Can be called without instantiating the Locality class.
1201
+
1202
+ ##### `Locality.deleteDatabase(name: string): Promise<void>`
1203
+
1204
+ Deletes an IndexedDB database by name (static method).
1205
+
1206
+ **Parameters:**
1207
+
1208
+ - `name`: The name of the database to delete
1209
+
1210
+ **Returns:** Promise that resolves when the database is deleted
1211
+
1212
+ **Example:**
1213
+
1214
+ ```typescript
1215
+ import { Locality } from 'locality-idb';
1216
+
1217
+ // Delete a database without creating an instance
1218
+ await Locality.deleteDatabase('old-database');
1219
+
1220
+ // Alternative: Get list of databases first
1221
+ const databases = await Locality.getDatabaseList();
1222
+ for (const db of databases) {
1223
+ if (db.name.startsWith('temp-')) {
1224
+ await Locality.deleteDatabase(db.name);
1225
+ }
1226
+ }
1227
+ ```
1228
+
1229
+ > **Warning:** This will permanently remove all data from the specified database and cannot be undone.
1230
+ >
1231
+ > **Note:** This is a static method that can be called without creating a Locality instance. For deleting the current database instance, use the instance method `db.deleteDB()` instead.
1232
+
830
1233
  ---
831
1234
 
832
1235
  ### Schema Functions
@@ -1028,6 +1431,40 @@ const emailColumn = column.text().validate((val) => { /* ... */ });
1028
1431
  const validatorFn = emailColumn[ValidateFn]; // Function reference
1029
1432
  ```
1030
1433
 
1434
+ #### `onUpdate<T>(updater: (currentValue: T) => T): Column`
1435
+
1436
+ Sets a function to auto-update the column value during update operations.
1437
+
1438
+ ```typescript
1439
+ const schema = defineSchema({
1440
+ users: {
1441
+ id: column.int().pk().auto(),
1442
+ name: column.text(),
1443
+ updatedAt: column.timestamp().onUpdate(() => getTimestamp()),
1444
+ },
1445
+ });
1446
+ ```
1447
+
1448
+ > **Note:**
1449
+ >
1450
+ > - The updater function is called automatically during update operations.
1451
+ > - **Important**: It overrides any value provided during updates.
1452
+ > - It receives the current value of the column and should return the updated value.
1453
+ > - This is useful for fields like `"updatedAt"` timestamps that need to be refreshed on each update.
1454
+ > - If multiple updaters are chained, only the last one is used.
1455
+ > - Should not be used with auto-generated indexed columns like primary keys.
1456
+ > - The updated value is validated according to the column's type and custom validators (if any).
1457
+
1458
+ **Access the `OnUpdate` symbol (advanced):**
1459
+
1460
+ ```typescript
1461
+ import { OnUpdate } from 'locality-idb';
1462
+
1463
+ // Access updater function programmatically
1464
+ const updatedAtColumn = column.timestamp().onUpdate(() => getTimestamp());
1465
+ const updaterFn = updatedAtColumn[OnUpdate]; // Function reference
1466
+ ```
1467
+
1031
1468
  ---
1032
1469
 
1033
1470
  ### Query Methods
@@ -1329,6 +1766,86 @@ isTimestamp('invalid'); // false
1329
1766
  isTimestamp(123); // false
1330
1767
  ```
1331
1768
 
1769
+ #### `isUUID(value: unknown): value is UUID<UUIDVersion>`
1770
+
1771
+ Checks if a value is a valid UUID string (v1, v4, or v5).
1772
+
1773
+ **Parameters:**
1774
+
1775
+ - `value`: The value to check
1776
+
1777
+ **Returns:** `true` if the value is a valid UUID, otherwise `false`
1778
+
1779
+ **Example:**
1780
+
1781
+ ```typescript
1782
+ import { isUUID } from 'locality-idb';
1783
+
1784
+ // Valid UUIDs
1785
+ isUUID('d9428888-122b-11e8-b642-0ed5f89f718b'); // true (v1)
1786
+ isUUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // true (v1)
1787
+ isUUID('550e8400-e29b-41d4-a716-446655440000'); // true (v4)
1788
+
1789
+ // Invalid formats
1790
+ isUUID('not-a-uuid'); // false
1791
+ isUUID('12345678-1234-1234-1234-123456789abc'); // false (invalid version)
1792
+ isUUID(123456789); // false
1793
+ ```
1794
+
1795
+ #### `isEmail(value: unknown): value is EmailString`
1796
+
1797
+ Checks if a value is a valid email string.
1798
+
1799
+ **Parameters:**
1800
+
1801
+ - `value`: The value to check
1802
+
1803
+ **Returns:** `true` if the value is a valid email, otherwise `false`
1804
+
1805
+ **Example:**
1806
+
1807
+ ```typescript
1808
+ import { isEmail } from 'locality-idb';
1809
+
1810
+ // Valid emails
1811
+ isEmail('user@example.com'); // true
1812
+ isEmail('first.last@sub.domain.co.uk'); // true
1813
+ isEmail('user+filter@example.org'); // true
1814
+
1815
+ // Invalid emails
1816
+ isEmail('plain-string'); // false
1817
+ isEmail('user@.com'); // false
1818
+ isEmail('@example.com'); // false
1819
+ isEmail('user@domain'); // false
1820
+ isEmail(12345); // false
1821
+ ```
1822
+
1823
+ #### `isURL(value: unknown): value is URLString`
1824
+
1825
+ Checks if a value is a valid URL string.
1826
+
1827
+ **Parameters:**
1828
+
1829
+ - `value`: The value to check
1830
+
1831
+ **Returns:** `true` if the value is a valid URL, otherwise `false`
1832
+
1833
+ **Example:**
1834
+
1835
+ ```typescript
1836
+ import { isURL } from 'locality-idb';
1837
+
1838
+ // Valid URLs
1839
+ isURL('https://example.com'); // true
1840
+ isURL('ftp://files.test/path?q=1'); // true
1841
+
1842
+ // Invalid URLs
1843
+ isURL('example.com'); // false (missing protocol)
1844
+ isURL('http://'); // false (empty domain)
1845
+ isURL('//cdn.domain/image.png'); // false (`URL` constructor cannot parse it)
1846
+ isURL(123456); // false
1847
+ ```
1848
+
1332
1849
  #### `openDBWithStores(name: string, stores: StoreConfig[], version?: number): Promise<IDBDatabase>`
1333
1850
 
1334
1851
  Opens an IndexedDB database with specified stores (low-level API).
@@ -1339,7 +1856,7 @@ Opens an IndexedDB database with specified stores (low-level API).
1339
1856
 
1340
1857
  - `name`: Database name
1341
1858
  - `stores`: Array of store configurations
1342
- - `version`: Database version (optional, default: 1)
1859
+ - `version`: Database version (optional, default: `undefined`)
1343
1860
 
1344
1861
  **Returns:** Promise resolving to `IDBDatabase` instance
1345
1862
 
@@ -1530,6 +2047,48 @@ type Selected = SelectFields<User, { name: true; email: true }>;
1530
2047
  // { name: string; email: string }
1531
2048
  ```
1532
2049
 
2050
+ ### Transaction & Export Types
2051
+
2052
+ ```typescript
2053
+ import type {
2054
+ TransactionContext,
2055
+ TransactionCallback,
2056
+ ExportOptions,
2057
+ ExportData,
2058
+ } from 'locality-idb';
2059
+
2060
+ // TransactionContext: Context object provided to transaction callback
2061
+ type TxContext = TransactionContext<Schema, TableName, ['users', 'posts']>;
2062
+
2063
+ // TransactionCallback: Function signature for transaction operations
2064
+ type TxCallback = TransactionCallback<Schema, TableName, ['users']>;
2065
+
2066
+ // ExportOptions: Configuration options for database export
2067
+ type ExportOpts = ExportOptions<'users' | 'posts'>;
2068
+ /*
2069
+ {
2070
+ tables?: ('users' | 'posts')[];
2071
+ filename?: string;
2072
+ pretty?: boolean;
2073
+ includeMetadata?: boolean;
2074
+ }
2075
+ */
2076
+
2077
+ // ExportData: Structure of exported database data
2078
+ type Exported = ExportData;
2079
+ /*
2080
+ {
2081
+ metadata?: {
2082
+ dbName: string;
2083
+ version: number;
2084
+ exportedAt: Timestamp;
2085
+ tables: string[];
2086
+ };
2087
+ data: Record<string, GenericObject[]>;
2088
+ }
2089
+ */
2090
+ ```
2091
+
1533
2092
  ---
1534
2093
 
1535
2094
  ## 📄 License