s3db.js 4.0.2 → 4.1.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
@@ -263,6 +263,75 @@ const users = await s3db.createResource({
263
263
  });
264
264
  ```
265
265
 
266
+ #### Nested Attributes
267
+
268
+ `s3db.js` fully supports nested object attributes, allowing you to create complex document structures:
269
+
270
+ ```javascript
271
+ const users = await s3db.createResource({
272
+ name: "users",
273
+ attributes: {
274
+ name: "string|required",
275
+ email: "email|required",
276
+ utm: {
277
+ source: "string|required",
278
+ medium: "string|required",
279
+ campaign: "string|required",
280
+ term: "string|optional",
281
+ content: "string|optional"
282
+ },
283
+ address: {
284
+ street: "string|required",
285
+ city: "string|required",
286
+ state: "string|required",
287
+ country: "string|required",
288
+ zipCode: "string|optional"
289
+ },
290
+ metadata: {
291
+ category: "string|required",
292
+ priority: "string|required",
293
+ settings: "object|optional"
294
+ }
295
+ }
296
+ });
297
+
298
+ // Insert data with nested objects
299
+ const user = await users.insert({
300
+ name: "John Doe",
301
+ email: "john@example.com",
302
+ utm: {
303
+ source: "google",
304
+ medium: "cpc",
305
+ campaign: "brand_awareness",
306
+ term: "search term"
307
+ },
308
+ address: {
309
+ street: "123 Main St",
310
+ city: "San Francisco",
311
+ state: "California",
312
+ country: "US",
313
+ zipCode: "94105"
314
+ },
315
+ metadata: {
316
+ category: "premium",
317
+ priority: "high",
318
+ settings: { theme: "dark", notifications: true }
319
+ }
320
+ });
321
+
322
+ // Access nested data
323
+ console.log(user.utm.source); // "google"
324
+ console.log(user.address.city); // "San Francisco"
325
+ console.log(user.metadata.category); // "premium"
326
+ ```
327
+
328
+ **Key features of nested attributes:**
329
+ - **Deep nesting**: Support for multiple levels of nested objects
330
+ - **Validation**: Each nested field can have its own validation rules
331
+ - **Optional fields**: Nested objects can contain optional fields
332
+ - **Mixed types**: Combine simple types, arrays, and nested objects
333
+ - **Partition support**: Use dot notation for partitions on nested fields (e.g., `"utm.source"`, `"address.country"`)
334
+
266
335
  #### Automatic Timestamps
267
336
 
268
337
  If you enable the `timestamps` option, `s3db.js` will automatically add `createdAt` and `updatedAt` fields to your resource, and keep them updated on insert and update operations.
@@ -456,6 +525,31 @@ const resource = await s3db.createResource({
456
525
  zipCode: "string|optional"
457
526
  },
458
527
 
528
+ // Complex nested structures
529
+ profile: {
530
+ bio: "string|max:500|optional",
531
+ avatar: "url|optional",
532
+ birthDate: "date|optional",
533
+ preferences: {
534
+ theme: "string|enum:light,dark|default:light",
535
+ language: "string|enum:en,es,fr|default:en",
536
+ notifications: "boolean|default:true"
537
+ }
538
+ },
539
+
540
+ // Nested objects with validation
541
+ contact: {
542
+ phone: {
543
+ mobile: "string|pattern:^\\+?[1-9]\\d{1,14}$|optional",
544
+ work: "string|pattern:^\\+?[1-9]\\d{1,14}$|optional"
545
+ },
546
+ social: {
547
+ twitter: "string|optional",
548
+ linkedin: "url|optional",
549
+ github: "url|optional"
550
+ }
551
+ },
552
+
459
553
  // Arrays
460
554
  tags: "array|items:string|unique",
461
555
  scores: "array|items:number|min:1",
@@ -467,6 +561,19 @@ const resource = await s3db.createResource({
467
561
  metadata: {
468
562
  settings: "object|optional",
469
563
  preferences: "object|optional"
564
+ },
565
+
566
+ // Analytics and tracking
567
+ analytics: {
568
+ utm: {
569
+ source: "string|optional",
570
+ medium: "string|optional",
571
+ campaign: "string|optional",
572
+ term: "string|optional",
573
+ content: "string|optional"
574
+ },
575
+ events: "array|items:object|optional",
576
+ lastVisit: "date|optional"
470
577
  }
471
578
  },
472
579
 
@@ -582,7 +689,104 @@ const attributes = {
582
689
  };
583
690
  ```
584
691
 
585
- ### Enhanced Array and Object Handling
692
+ #### Nested Object Validation
693
+
694
+ Nested objects support comprehensive validation rules at each level:
695
+
696
+ ```javascript
697
+ const users = await s3db.createResource({
698
+ name: "users",
699
+ attributes: {
700
+ name: "string|min:2|max:100",
701
+ email: "email|unique",
702
+
703
+ // Simple nested object
704
+ profile: {
705
+ bio: "string|max:500|optional",
706
+ avatar: "url|optional",
707
+ birthDate: "date|optional"
708
+ },
709
+
710
+ // Complex nested structure with validation
711
+ contact: {
712
+ phone: {
713
+ mobile: "string|pattern:^\\+?[1-9]\\d{1,14}$|optional",
714
+ work: "string|pattern:^\\+?[1-9]\\d{1,14}$|optional"
715
+ },
716
+ social: {
717
+ twitter: "string|optional",
718
+ linkedin: "url|optional"
719
+ }
720
+ },
721
+
722
+ // Nested object with arrays
723
+ preferences: {
724
+ categories: "array|items:string|unique|optional",
725
+ notifications: {
726
+ email: "boolean|default:true",
727
+ sms: "boolean|default:false",
728
+ push: "boolean|default:true"
729
+ }
730
+ },
731
+
732
+ // Deep nesting with validation
733
+ analytics: {
734
+ tracking: {
735
+ utm: {
736
+ source: "string|optional",
737
+ medium: "string|optional",
738
+ campaign: "string|optional"
739
+ },
740
+ events: "array|items:object|optional"
741
+ }
742
+ }
743
+ }
744
+ });
745
+
746
+ // Insert data with complex nested structure
747
+ const user = await users.insert({
748
+ name: "John Doe",
749
+ email: "john@example.com",
750
+ profile: {
751
+ bio: "Software developer with 10+ years of experience",
752
+ avatar: "https://example.com/avatar.jpg",
753
+ birthDate: new Date("1990-01-15")
754
+ },
755
+ contact: {
756
+ phone: {
757
+ mobile: "+1234567890",
758
+ work: "+1987654321"
759
+ },
760
+ social: {
761
+ twitter: "@johndoe",
762
+ linkedin: "https://linkedin.com/in/johndoe"
763
+ }
764
+ },
765
+ preferences: {
766
+ categories: ["technology", "programming", "web-development"],
767
+ notifications: {
768
+ email: true,
769
+ sms: false,
770
+ push: true
771
+ }
772
+ },
773
+ analytics: {
774
+ tracking: {
775
+ utm: {
776
+ source: "google",
777
+ medium: "organic",
778
+ campaign: "brand"
779
+ },
780
+ events: [
781
+ { type: "page_view", timestamp: new Date() },
782
+ { type: "signup", timestamp: new Date() }
783
+ ]
784
+ }
785
+ }
786
+ });
787
+ ```
788
+
789
+ #### Enhanced Array and Object Handling
586
790
 
587
791
  s3db.js now provides robust serialization for complex data structures:
588
792
 
@@ -1119,7 +1323,8 @@ const users = await s3db.createResource({
1119
1323
  name: "string",
1120
1324
  email: "email",
1121
1325
  region: "string",
1122
- ageGroup: "string"
1326
+ ageGroup: "string",
1327
+ createdAt: "date"
1123
1328
  },
1124
1329
  options: {
1125
1330
  partitions: {
@@ -1128,6 +1333,9 @@ const users = await s3db.createResource({
1128
1333
  },
1129
1334
  byAgeGroup: {
1130
1335
  fields: { ageGroup: "string" }
1336
+ },
1337
+ byDate: {
1338
+ fields: { createdAt: "date|maxlength:10" }
1131
1339
  }
1132
1340
  }
1133
1341
  }
@@ -1136,12 +1344,68 @@ const users = await s3db.createResource({
1136
1344
 
1137
1345
  ### Querying by partition
1138
1346
 
1347
+ Partitions are automatically created when you insert documents, and you can query them using specific methods that accept partition parameters:
1348
+
1349
+ #### List IDs by partition
1350
+
1351
+ ```js
1352
+ // Get all user IDs in the 'south' region
1353
+ const userIds = await users.listIds({
1354
+ partition: "byRegion",
1355
+ partitionValues: { region: "south" }
1356
+ });
1357
+
1358
+ // Get all user IDs in the 'adult' age group
1359
+ const adultIds = await users.listIds({
1360
+ partition: "byAgeGroup",
1361
+ partitionValues: { ageGroup: "adult" }
1362
+ });
1363
+ ```
1364
+
1365
+ #### Count documents by partition
1366
+
1139
1367
  ```js
1140
- // Find all users in the 'south' region
1141
- const usersSouth = await users.query({ region: "south" });
1368
+ // Count users in the 'south' region
1369
+ const count = await users.count({
1370
+ partition: "byRegion",
1371
+ partitionValues: { region: "south" }
1372
+ });
1142
1373
 
1143
- // Find all users in the 'adult' age group
1144
- const adults = await users.query({ ageGroup: "adult" });
1374
+ // Count adult users
1375
+ const adultCount = await users.count({
1376
+ partition: "byAgeGroup",
1377
+ partitionValues: { ageGroup: "adult" }
1378
+ });
1379
+ ```
1380
+
1381
+ #### List objects by partition
1382
+
1383
+ ```js
1384
+ // Get all users in the 'south' region
1385
+ const usersSouth = await users.listByPartition({
1386
+ partition: "byRegion",
1387
+ partitionValues: { region: "south" }
1388
+ });
1389
+
1390
+ // Get all adult users with pagination
1391
+ const adultUsers = await users.listByPartition(
1392
+ { partition: "byAgeGroup", partitionValues: { ageGroup: "adult" } },
1393
+ { limit: 10, offset: 0 }
1394
+ );
1395
+ ```
1396
+
1397
+ #### Page through partition data
1398
+
1399
+ ```js
1400
+ // Get first page of users in 'south' region
1401
+ const page = await users.page(0, 10, {
1402
+ partition: "byRegion",
1403
+ partitionValues: { region: "south" }
1404
+ });
1405
+
1406
+ console.log(page.items); // Array of user objects
1407
+ console.log(page.totalItems); // Total count in this partition
1408
+ console.log(page.totalPages); // Total pages available
1145
1409
  ```
1146
1410
 
1147
1411
  ### Example: Time-based partition
@@ -1163,10 +1427,202 @@ const logs = await s3db.createResource({
1163
1427
  }
1164
1428
  });
1165
1429
 
1430
+ // Insert logs (partitions are created automatically)
1431
+ await logs.insert({
1432
+ message: "User login",
1433
+ level: "info",
1434
+ createdAt: new Date("2024-06-27")
1435
+ });
1436
+
1166
1437
  // Query logs for a specific day
1167
- const logsToday = await logs.query({ createdAt: "2024-06-27" });
1438
+ const logsToday = await logs.listByPartition({
1439
+ partition: "byDate",
1440
+ partitionValues: { createdAt: "2024-06-27" }
1441
+ });
1442
+
1443
+ // Count logs for a specific day
1444
+ const count = await logs.count({
1445
+ partition: "byDate",
1446
+ partitionValues: { createdAt: "2024-06-27" }
1447
+ });
1168
1448
  ```
1169
1449
 
1450
+ ### Partitions with Nested Fields
1451
+
1452
+ `s3db.js` supports partitions using nested object fields using dot notation, just like the schema mapper:
1453
+
1454
+ ```js
1455
+ const users = await s3db.createResource({
1456
+ name: "users",
1457
+ attributes: {
1458
+ name: "string|required",
1459
+ utm: {
1460
+ source: "string|required",
1461
+ medium: "string|required",
1462
+ campaign: "string|required"
1463
+ },
1464
+ address: {
1465
+ country: "string|required",
1466
+ state: "string|required",
1467
+ city: "string|required"
1468
+ },
1469
+ metadata: {
1470
+ category: "string|required",
1471
+ priority: "string|required"
1472
+ }
1473
+ },
1474
+ options: {
1475
+ partitions: {
1476
+ byUtmSource: {
1477
+ fields: {
1478
+ "utm.source": "string"
1479
+ }
1480
+ },
1481
+ byAddressCountry: {
1482
+ fields: {
1483
+ "address.country": "string|maxlength:2"
1484
+ }
1485
+ },
1486
+ byAddressState: {
1487
+ fields: {
1488
+ "address.country": "string|maxlength:2",
1489
+ "address.state": "string"
1490
+ }
1491
+ },
1492
+ byUtmAndAddress: {
1493
+ fields: {
1494
+ "utm.source": "string",
1495
+ "utm.medium": "string",
1496
+ "address.country": "string|maxlength:2"
1497
+ }
1498
+ }
1499
+ }
1500
+ }
1501
+ });
1502
+
1503
+ // Insert user with nested data
1504
+ await users.insert({
1505
+ name: "John Doe",
1506
+ utm: {
1507
+ source: "google",
1508
+ medium: "cpc",
1509
+ campaign: "brand"
1510
+ },
1511
+ address: {
1512
+ country: "US",
1513
+ state: "California",
1514
+ city: "San Francisco"
1515
+ },
1516
+ metadata: {
1517
+ category: "premium",
1518
+ priority: "high"
1519
+ }
1520
+ });
1521
+
1522
+ // Query by nested UTM source
1523
+ const googleUsers = await users.listIds({
1524
+ partition: "byUtmSource",
1525
+ partitionValues: { "utm.source": "google" }
1526
+ });
1527
+
1528
+ // Query by nested address country
1529
+ const usUsers = await users.listIds({
1530
+ partition: "byAddressCountry",
1531
+ partitionValues: { "address.country": "US" }
1532
+ });
1533
+
1534
+ // Query by multiple nested fields
1535
+ const usCaliforniaUsers = await users.listIds({
1536
+ partition: "byAddressState",
1537
+ partitionValues: {
1538
+ "address.country": "US",
1539
+ "address.state": "California"
1540
+ }
1541
+ });
1542
+
1543
+ // Complex query with UTM and address
1544
+ const googleCpcUsUsers = await users.listIds({
1545
+ partition: "byUtmAndAddress",
1546
+ partitionValues: {
1547
+ "utm.source": "google",
1548
+ "utm.medium": "cpc",
1549
+ "address.country": "US"
1550
+ }
1551
+ });
1552
+
1553
+ // Count and list operations work the same way
1554
+ const googleCount = await users.count({
1555
+ partition: "byUtmSource",
1556
+ partitionValues: { "utm.source": "google" }
1557
+ });
1558
+
1559
+ const googleUsersData = await users.listByPartition({
1560
+ partition: "byUtmSource",
1561
+ partitionValues: { "utm.source": "google" }
1562
+ });
1563
+ ```
1564
+
1565
+ **Key features of nested field partitions:**
1566
+
1567
+ - **Dot notation**: Use `"parent.child"` to access nested fields
1568
+ - **Multiple levels**: Support for deeply nested objects like `"address.country.state"`
1569
+ - **Mixed partitions**: Combine nested and flat fields in the same partition
1570
+ - **Rules support**: Apply maxlength, date formatting, etc. to nested fields
1571
+ - **Automatic flattening**: Uses the same flattening logic as the schema mapper
1572
+
1573
+ ### Partition rules and transformations
1574
+
1575
+ Partitions support various field rules that automatically transform values:
1576
+
1577
+ ```js
1578
+ const products = await s3db.createResource({
1579
+ name: "products",
1580
+ attributes: {
1581
+ name: "string",
1582
+ category: "string",
1583
+ price: "number",
1584
+ createdAt: "date"
1585
+ },
1586
+ options: {
1587
+ partitions: {
1588
+ byCategory: {
1589
+ fields: { category: "string" }
1590
+ },
1591
+ byDate: {
1592
+ fields: { createdAt: "date|maxlength:10" } // Truncates to YYYY-MM-DD
1593
+ }
1594
+ }
1595
+ }
1596
+ });
1597
+
1598
+ // Date values are automatically formatted
1599
+ await products.insert({
1600
+ name: "Widget",
1601
+ category: "electronics",
1602
+ price: 99.99,
1603
+ createdAt: new Date("2024-06-27T15:30:00Z") // Will be stored as "2024-06-27"
1604
+ });
1605
+ ```
1606
+
1607
+ ### Important notes about partitions
1608
+
1609
+ 1. **Automatic creation**: Partitions are automatically created when you insert documents
1610
+ 2. **Performance**: Partition queries are more efficient than filtering all documents
1611
+ 3. **Storage**: Each partition creates additional S3 objects, increasing storage costs
1612
+ 4. **Consistency**: Partition data is automatically kept in sync with main resource data
1613
+ 5. **Field requirements**: All partition fields must exist in your resource attributes
1614
+
1615
+ ### Available partition-aware methods
1616
+
1617
+ | Method | Description | Partition Support |
1618
+ |--------|-------------|-------------------|
1619
+ | `listIds()` | Get array of document IDs | ✅ `{ partition, partitionValues }` |
1620
+ | `count()` | Count documents | ✅ `{ partition, partitionValues }` |
1621
+ | `listByPartition()` | List documents by partition | ✅ `{ partition, partitionValues }` |
1622
+ | `page()` | Paginate documents | ✅ `{ partition, partitionValues }` |
1623
+ | `getFromPartition()` | Get single document from partition | ✅ Direct partition access |
1624
+ | `query()` | Filter documents in memory | ❌ No partition support |
1625
+
1170
1626
  ## Hooks
1171
1627
 
1172
1628
  `s3db.js` provides a powerful hooks system to let you run custom logic before and after key operations on your resources. Hooks can be used for validation, transformation, logging, or any custom workflow.
package/dist/s3db.cjs.js CHANGED
@@ -3482,9 +3482,10 @@ class Schema {
3482
3482
  this.attributes = attributes || {};
3483
3483
  this.passphrase = passphrase ?? "secret";
3484
3484
  this.options = lodashEs.merge({}, this.defaultOptions(), options);
3485
+ const processedAttributes = this.preprocessAttributesForValidation(this.attributes);
3485
3486
  this.validator = new ValidatorManager({ autoEncrypt: false }).compile(lodashEs.merge(
3486
3487
  { $$async: true },
3487
- lodashEs.cloneDeep(this.attributes)
3488
+ processedAttributes
3488
3489
  ));
3489
3490
  if (this.options.generateAutoHooks) this.generateAutoHooks();
3490
3491
  if (!lodashEs.isEmpty(map)) {
@@ -3630,6 +3631,26 @@ class Schema {
3630
3631
  await this.applyHooksActions(rest, "afterUnmap");
3631
3632
  return flat.unflatten(rest);
3632
3633
  }
3634
+ /**
3635
+ * Preprocess attributes to convert nested objects into validator-compatible format
3636
+ * @param {Object} attributes - Original attributes
3637
+ * @returns {Object} Processed attributes for validator
3638
+ */
3639
+ preprocessAttributesForValidation(attributes) {
3640
+ const processed = {};
3641
+ for (const [key, value] of Object.entries(attributes)) {
3642
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
3643
+ processed[key] = {
3644
+ type: "object",
3645
+ properties: this.preprocessAttributesForValidation(value),
3646
+ strict: false
3647
+ };
3648
+ } else {
3649
+ processed[key] = value;
3650
+ }
3651
+ }
3652
+ return processed;
3653
+ }
3633
3654
  }
3634
3655
 
3635
3656
  var global$1 = (typeof global !== "undefined" ? global :
@@ -8945,7 +8966,7 @@ class Resource extends EventEmitter {
8945
8966
  continue;
8946
8967
  }
8947
8968
  for (const fieldName of Object.keys(partitionDef.fields)) {
8948
- if (!currentAttributes.includes(fieldName)) {
8969
+ if (!this.fieldExistsInAttributes(fieldName)) {
8949
8970
  throw new Error(
8950
8971
  `Partition '${partitionName}' uses field '${fieldName}' which does not exist in resource version '${this.version}'. Available fields: ${currentAttributes.join(", ")}. This version of resource does not have support for this partition.`
8951
8972
  );
@@ -8953,6 +8974,25 @@ class Resource extends EventEmitter {
8953
8974
  }
8954
8975
  }
8955
8976
  }
8977
+ /**
8978
+ * Check if a field (including nested fields) exists in the current attributes
8979
+ * @param {string} fieldName - Field name (can be nested like 'utm.source')
8980
+ * @returns {boolean} True if field exists
8981
+ */
8982
+ fieldExistsInAttributes(fieldName) {
8983
+ if (!fieldName.includes(".")) {
8984
+ return Object.keys(this.attributes || {}).includes(fieldName);
8985
+ }
8986
+ const keys = fieldName.split(".");
8987
+ let currentLevel = this.attributes || {};
8988
+ for (const key of keys) {
8989
+ if (!currentLevel || typeof currentLevel !== "object" || !(key in currentLevel)) {
8990
+ return false;
8991
+ }
8992
+ currentLevel = currentLevel[key];
8993
+ }
8994
+ return true;
8995
+ }
8956
8996
  /**
8957
8997
  * Apply a single partition rule to a field value
8958
8998
  * @param {*} value - The field value
@@ -9015,17 +9055,38 @@ class Resource extends EventEmitter {
9015
9055
  const partitionSegments = [];
9016
9056
  const sortedFields = Object.entries(partition.fields).sort(([a], [b]) => a.localeCompare(b));
9017
9057
  for (const [fieldName, rule] of sortedFields) {
9018
- const fieldValue = this.applyPartitionRule(data[fieldName], rule);
9019
- if (fieldValue === void 0 || fieldValue === null) {
9058
+ const fieldValue = this.getNestedFieldValue(data, fieldName);
9059
+ const transformedValue = this.applyPartitionRule(fieldValue, rule);
9060
+ if (transformedValue === void 0 || transformedValue === null) {
9020
9061
  return null;
9021
9062
  }
9022
- partitionSegments.push(`${fieldName}=${fieldValue}`);
9063
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
9023
9064
  }
9024
9065
  if (partitionSegments.length === 0) {
9025
9066
  return null;
9026
9067
  }
9027
9068
  return join(`resource=${this.name}`, `partition=${partitionName}`, ...partitionSegments, `id=${id}`);
9028
9069
  }
9070
+ /**
9071
+ * Get nested field value from data object using dot notation
9072
+ * @param {Object} data - Data object
9073
+ * @param {string} fieldPath - Field path (e.g., "utm.source", "address.city")
9074
+ * @returns {*} Field value
9075
+ */
9076
+ getNestedFieldValue(data, fieldPath) {
9077
+ if (!fieldPath.includes(".")) {
9078
+ return data[fieldPath];
9079
+ }
9080
+ const keys = fieldPath.split(".");
9081
+ let value = data;
9082
+ for (const key of keys) {
9083
+ if (value === null || value === void 0 || typeof value !== "object") {
9084
+ return void 0;
9085
+ }
9086
+ value = value[key];
9087
+ }
9088
+ return value;
9089
+ }
9029
9090
  async insert({ id, ...attributes }) {
9030
9091
  if (this.options.timestamps) {
9031
9092
  attributes.createdAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -9701,7 +9762,7 @@ class Database extends EventEmitter {
9701
9762
  this.version = "1";
9702
9763
  this.s3dbVersion = (() => {
9703
9764
  try {
9704
- return true ? "4.0.1" : "latest";
9765
+ return true ? "4.0.2" : "latest";
9705
9766
  } catch (e) {
9706
9767
  return "latest";
9707
9768
  }