@typeb-digital/nucleus-sdk 0.1.1 → 0.2.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.
@@ -415,6 +415,30 @@ function transformJurisdiction(raw) {
415
415
  if ('country' in r) out['country'] = str(r['country']);
416
416
  return out;
417
417
  }
418
+ function transformJobLevel(raw) {
419
+ if (!raw || typeof raw !== 'object') return {};
420
+ const r = raw;
421
+ const out = {};
422
+ if ('id' in r) out['id'] = r['id'];
423
+ if ('name' in r) out['name'] = str(r['name']);
424
+ if ('code' in r) out['code'] = str(r['code']);
425
+ if ('department_id' in r) out['departmentId'] = str(r['department_id']);
426
+ if ('sort_order' in r) out['sortOrder'] = num(r['sort_order']) ?? 0;
427
+ return out;
428
+ }
429
+ function transformSalaryBand(raw) {
430
+ if (!raw || typeof raw !== 'object') return {};
431
+ const r = raw;
432
+ const out = {};
433
+ if ('id' in r) out['id'] = r['id'];
434
+ if ('job_level_id' in r) out['jobLevelId'] = str(r['job_level_id']);
435
+ if ('jurisdiction_id' in r) out['jurisdictionId'] = str(r['jurisdiction_id']);
436
+ if ('currency_code' in r) out['currencyCode'] = str(r['currency_code']);
437
+ if ('min_salary' in r) out['minSalary'] = num(r['min_salary']);
438
+ if ('max_salary' in r) out['maxSalary'] = num(r['max_salary']);
439
+ if ('target_salary' in r) out['targetSalary'] = num(r['target_salary']);
440
+ return out;
441
+ }
418
442
  function transformLeaveType(raw) {
419
443
  if (!raw || typeof raw !== 'object') return {};
420
444
  const r = raw;
@@ -566,6 +590,7 @@ class EmployeesAccessor {
566
590
  if (params?.employmentStatus) query['employmentStatus'] = params.employmentStatus;
567
591
  if (params?.page) query['page'] = params.page;
568
592
  if (params?.pageSize) query['pageSize'] = params.pageSize;
593
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
569
594
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
570
595
  const result = await this.transport.getList('/api/v1/data/employees', query);
571
596
  if ('error' in result) return result;
@@ -585,6 +610,14 @@ class EmployeesAccessor {
585
610
  }
586
611
  }
587
612
 
613
+ function transformMember(raw) {
614
+ return {
615
+ id: raw['id'],
616
+ projectId: raw['project_id'],
617
+ employeeId: raw['employee_id'],
618
+ role: raw['role'] ?? null
619
+ };
620
+ }
588
621
  class ProjectsAccessor {
589
622
  constructor(transport){
590
623
  this.transport = transport;
@@ -593,9 +626,11 @@ class ProjectsAccessor {
593
626
  const query = {};
594
627
  if (params?.search) query['search'] = params.search;
595
628
  if (params?.status) query['status'] = params.status;
629
+ if (params?.stage) query['stage'] = params.stage;
596
630
  if (params?.clientId) query['clientId'] = params.clientId;
597
631
  if (params?.page) query['page'] = params.page;
598
632
  if (params?.pageSize) query['pageSize'] = params.pageSize;
633
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
599
634
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
600
635
  const result = await this.transport.getList('/api/v1/data/projects', query);
601
636
  if ('error' in result) return result;
@@ -634,6 +669,33 @@ class ProjectsAccessor {
634
669
  data: null
635
670
  };
636
671
  }
672
+ // ─── Team membership (the projects `team` bucket) ──────────────────────────
673
+ // Membership is the project↔employee join. Reads need `projects` read on `team`;
674
+ // add/remove need `projects` update on `team` (re-adding restores a soft-deleted row).
675
+ membersBase(projectId) {
676
+ return `/api/v1/data/project-memberships/projects/${projectId}/members`;
677
+ }
678
+ /** List a project's team members. Requires `projects` read on the `team` bucket. */ async listMembers(projectId) {
679
+ const result = await this.transport.get(this.membersBase(projectId));
680
+ if ('error' in result) return result;
681
+ return {
682
+ data: (result.data ?? []).map((r)=>transformMember(r))
683
+ };
684
+ }
685
+ /** Add a team member. Requires `projects` update on the `team` bucket. */ async addMember(projectId, data) {
686
+ const result = await this.transport.post(this.membersBase(projectId), toSnakeBody(data));
687
+ if ('error' in result) return result;
688
+ return {
689
+ data: transformMember(result.data)
690
+ };
691
+ }
692
+ /** Remove a team member. Requires `projects` update on the `team` bucket. */ async removeMember(projectId, employeeId) {
693
+ const result = await this.transport.del(`${this.membersBase(projectId)}/${employeeId}`);
694
+ if ('error' in result) return result;
695
+ return {
696
+ data: null
697
+ };
698
+ }
637
699
  }
638
700
 
639
701
  class ClientsAccessor {
@@ -647,6 +709,7 @@ class ClientsAccessor {
647
709
  if (params?.country) query['country'] = params.country;
648
710
  if (params?.page) query['page'] = params.page;
649
711
  if (params?.pageSize) query['pageSize'] = params.pageSize;
712
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
650
713
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
651
714
  const result = await this.transport.getList('/api/v1/data/clients', query);
652
715
  if ('error' in result) return result;
@@ -696,6 +759,7 @@ class PartnersAccessor {
696
759
  if (params?.search) query['search'] = params.search;
697
760
  if (params?.page) query['page'] = params.page;
698
761
  if (params?.pageSize) query['pageSize'] = params.pageSize;
762
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
699
763
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
700
764
  const result = await this.transport.getList('/api/v1/data/partners', query);
701
765
  if ('error' in result) return result;
@@ -745,6 +809,7 @@ class DepartmentsAccessor {
745
809
  if (params?.search) query['search'] = params.search;
746
810
  if (params?.page) query['page'] = params.page;
747
811
  if (params?.pageSize) query['pageSize'] = params.pageSize;
812
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
748
813
  const result = await this.transport.getList('/api/v1/data/departments', query);
749
814
  if ('error' in result) return result;
750
815
  return {
@@ -790,6 +855,7 @@ class ProjectTypesAccessor {
790
855
  const query = {};
791
856
  if (params?.page) query['page'] = params.page;
792
857
  if (params?.pageSize) query['pageSize'] = params.pageSize;
858
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
793
859
  const result = await this.transport.getList('/api/v1/data/project-types', query);
794
860
  if ('error' in result) return result;
795
861
  return {
@@ -835,6 +901,7 @@ class CurrenciesAccessor {
835
901
  const query = {};
836
902
  if (params?.page) query['page'] = params.page;
837
903
  if (params?.pageSize) query['pageSize'] = params.pageSize;
904
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
838
905
  const result = await this.transport.getList('/api/v1/data/currencies', query);
839
906
  if ('error' in result) return result;
840
907
  return {
@@ -884,6 +951,7 @@ class GenericRatesAccessor {
884
951
  if (params?.department) query['department'] = params.department;
885
952
  if (params?.page) query['page'] = params.page;
886
953
  if (params?.pageSize) query['pageSize'] = params.pageSize;
954
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
887
955
  const result = await this.transport.getList('/api/v1/data/generic-rates', query);
888
956
  if ('error' in result) return result;
889
957
  return {
@@ -930,6 +998,7 @@ class JurisdictionsAccessor {
930
998
  if (params?.search) query['search'] = params.search;
931
999
  if (params?.page) query['page'] = params.page;
932
1000
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1001
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
933
1002
  const result = await this.transport.getList('/api/v1/data/jurisdictions', query);
934
1003
  if ('error' in result) return result;
935
1004
  return {
@@ -967,6 +1036,105 @@ class JurisdictionsAccessor {
967
1036
  }
968
1037
  }
969
1038
 
1039
+ /** JobLevelsAccessor — the company's career-ladder rungs (WS7). */ class JobLevelsAccessor {
1040
+ constructor(transport){
1041
+ this.transport = transport;
1042
+ }
1043
+ async list(params) {
1044
+ const query = {};
1045
+ if (params?.search) query['search'] = params.search;
1046
+ if (params?.departmentId) query['departmentId'] = params.departmentId;
1047
+ if (params?.page) query['page'] = params.page;
1048
+ if (params?.pageSize) query['pageSize'] = params.pageSize;
1049
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1050
+ const result = await this.transport.getList('/api/v1/data/job-levels', query);
1051
+ if ('error' in result) return result;
1052
+ return {
1053
+ data: result.data.map((r)=>transformJobLevel(r)),
1054
+ meta: result.meta
1055
+ };
1056
+ }
1057
+ async getById(id) {
1058
+ const result = await this.transport.get(`/api/v1/data/job-levels/${id}`);
1059
+ if ('error' in result) return result;
1060
+ return {
1061
+ data: transformJobLevel(result.data)
1062
+ };
1063
+ }
1064
+ /** Create a job level. Requires `job-levels.create` scope. */ async create(data) {
1065
+ const result = await this.transport.post('/api/v1/data/job-levels', toSnakeBody(data));
1066
+ if ('error' in result) return result;
1067
+ return {
1068
+ data: transformJobLevel(result.data)
1069
+ };
1070
+ }
1071
+ /** Update a job level. Requires `job-levels.update` scope. */ async update(id, data) {
1072
+ const result = await this.transport.put(`/api/v1/data/job-levels/${id}`, toSnakeBody(data));
1073
+ if ('error' in result) return result;
1074
+ return {
1075
+ data: transformJobLevel(result.data)
1076
+ };
1077
+ }
1078
+ /** Soft-delete a job level. Requires `job-levels.delete` scope. */ async delete(id) {
1079
+ const result = await this.transport.del(`/api/v1/data/job-levels/${id}`);
1080
+ if ('error' in result) return result;
1081
+ return {
1082
+ data: null
1083
+ };
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * SalaryBandsAccessor — per-jurisdiction pay ranges for a job level (WS7).
1089
+ * The `financials` bucket (min/max/target) is flagged sensitive server-side.
1090
+ */ class SalaryBandsAccessor {
1091
+ constructor(transport){
1092
+ this.transport = transport;
1093
+ }
1094
+ async list(params) {
1095
+ const query = {};
1096
+ if (params?.jobLevelId) query['jobLevelId'] = params.jobLevelId;
1097
+ if (params?.jurisdictionId) query['jurisdictionId'] = params.jurisdictionId;
1098
+ if (params?.page) query['page'] = params.page;
1099
+ if (params?.pageSize) query['pageSize'] = params.pageSize;
1100
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1101
+ const result = await this.transport.getList('/api/v1/data/salary-bands', query);
1102
+ if ('error' in result) return result;
1103
+ return {
1104
+ data: result.data.map((r)=>transformSalaryBand(r)),
1105
+ meta: result.meta
1106
+ };
1107
+ }
1108
+ async getById(id) {
1109
+ const result = await this.transport.get(`/api/v1/data/salary-bands/${id}`);
1110
+ if ('error' in result) return result;
1111
+ return {
1112
+ data: transformSalaryBand(result.data)
1113
+ };
1114
+ }
1115
+ /** Create a salary band. Requires `salary-bands.create` scope. */ async create(data) {
1116
+ const result = await this.transport.post('/api/v1/data/salary-bands', toSnakeBody(data));
1117
+ if ('error' in result) return result;
1118
+ return {
1119
+ data: transformSalaryBand(result.data)
1120
+ };
1121
+ }
1122
+ /** Update a salary band. Requires `salary-bands.update` scope. */ async update(id, data) {
1123
+ const result = await this.transport.put(`/api/v1/data/salary-bands/${id}`, toSnakeBody(data));
1124
+ if ('error' in result) return result;
1125
+ return {
1126
+ data: transformSalaryBand(result.data)
1127
+ };
1128
+ }
1129
+ /** Soft-delete a salary band. Requires `salary-bands.delete` scope. */ async delete(id) {
1130
+ const result = await this.transport.del(`/api/v1/data/salary-bands/${id}`);
1131
+ if ('error' in result) return result;
1132
+ return {
1133
+ data: null
1134
+ };
1135
+ }
1136
+ }
1137
+
970
1138
  class LeaveTypesAccessor {
971
1139
  constructor(transport){
972
1140
  this.transport = transport;
@@ -976,6 +1144,7 @@ class LeaveTypesAccessor {
976
1144
  if (params?.search) query['search'] = params.search;
977
1145
  if (params?.page) query['page'] = params.page;
978
1146
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1147
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
979
1148
  const result = await this.transport.getList('/api/v1/data/leave-types', query);
980
1149
  if ('error' in result) return result;
981
1150
  return {
@@ -1024,6 +1193,7 @@ class LeavesAccessor {
1024
1193
  if (params?.status) query['status'] = params.status;
1025
1194
  if (params?.page) query['page'] = params.page;
1026
1195
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1196
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1027
1197
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
1028
1198
  const result = await this.transport.getList('/api/v1/data/leaves', query);
1029
1199
  if ('error' in result) return result;
@@ -1075,6 +1245,7 @@ class LeaveBalancesAccessor {
1075
1245
  if (params?.year) query['year'] = params.year;
1076
1246
  if (params?.page) query['page'] = params.page;
1077
1247
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1248
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1078
1249
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
1079
1250
  const result = await this.transport.getList('/api/v1/data/leave-balances', query);
1080
1251
  if ('error' in result) return result;
@@ -1125,6 +1296,7 @@ class PoliciesAccessor {
1125
1296
  if (params?.category) query['category'] = params.category;
1126
1297
  if (params?.page) query['page'] = params.page;
1127
1298
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1299
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1128
1300
  const result = await this.transport.getList('/api/v1/data/policies', query);
1129
1301
  if ('error' in result) return result;
1130
1302
  return {
@@ -1172,6 +1344,7 @@ class PolicyAcknowledgementsAccessor {
1172
1344
  if (params?.employeeId) query['employeeId'] = params.employeeId;
1173
1345
  if (params?.page) query['page'] = params.page;
1174
1346
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1347
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1175
1348
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
1176
1349
  const result = await this.transport.getList('/api/v1/data/policy-acknowledgements', query);
1177
1350
  if ('error' in result) return result;
@@ -1220,8 +1393,10 @@ class CalendarEventsAccessor {
1220
1393
  const query = {};
1221
1394
  if (params?.search) query['search'] = params.search;
1222
1395
  if (params?.type) query['type'] = params.type;
1396
+ if (params?.jurisdictionId) query['jurisdictionId'] = params.jurisdictionId;
1223
1397
  if (params?.page) query['page'] = params.page;
1224
1398
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1399
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1225
1400
  const result = await this.transport.getList('/api/v1/data/calendar-events', query);
1226
1401
  if ('error' in result) return result;
1227
1402
  return {
@@ -1269,6 +1444,7 @@ class TimesheetsAccessor {
1269
1444
  if (params?.projectId) query['projectId'] = params.projectId;
1270
1445
  if (params?.page) query['page'] = params.page;
1271
1446
  if (params?.pageSize) query['pageSize'] = params.pageSize;
1447
+ if (params?.updatedSince) query['updatedSince'] = params.updatedSince;
1272
1448
  if (params?.expand?.length) query['expand'] = params.expand.join(',');
1273
1449
  const result = await this.transport.getList('/api/v1/data/timesheets', query);
1274
1450
  if ('error' in result) return result;
@@ -1293,6 +1469,23 @@ class TimesheetsAccessor {
1293
1469
  data: transformTimesheet(result.data)
1294
1470
  };
1295
1471
  }
1472
+ /**
1473
+ * Bulk-create timesheet entries (partial accept). Requires `timesheets.create` scope.
1474
+ * Returns `{ created, rejected }` — every valid item is inserted; invalid items come back
1475
+ * with their input index + reason.
1476
+ */ async bulkCreate(items) {
1477
+ const result = await this.transport.post('/api/v1/data/timesheets/bulk', {
1478
+ items: items.map((i)=>toSnakeBody(i))
1479
+ });
1480
+ if ('error' in result) return result;
1481
+ const data = result.data;
1482
+ return {
1483
+ data: {
1484
+ created: data.created.map((r)=>transformTimesheet(r)),
1485
+ rejected: data.rejected
1486
+ }
1487
+ };
1488
+ }
1296
1489
  /** Update a timesheet entry. Requires `timesheets.update` scope. */ async update(id, data) {
1297
1490
  const result = await this.transport.put(`/api/v1/data/timesheets/${id}`, toSnakeBody(data));
1298
1491
  if ('error' in result) return result;
@@ -1309,6 +1502,98 @@ class TimesheetsAccessor {
1309
1502
  }
1310
1503
  }
1311
1504
 
1505
+ /**
1506
+ * CalendarAccessor — read workspace users' Google Calendar events (WS3).
1507
+ * Requires the app's `calendar:read` scope. Not part of the bucket/CRUD scope system.
1508
+ */ class CalendarAccessor {
1509
+ constructor(transport){
1510
+ this.transport = transport;
1511
+ }
1512
+ async listEvents(params) {
1513
+ const result = await this.transport.get('/api/v1/data/calendar/events', {
1514
+ employeeId: params.employeeId,
1515
+ timeMin: params.timeMin,
1516
+ timeMax: params.timeMax,
1517
+ q: params.q
1518
+ });
1519
+ if ('error' in result) return result;
1520
+ return {
1521
+ data: result.data
1522
+ };
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * EmailAccessor — send Nucleus-mediated email (WS5). Requires the app's `email:send` scope.
1528
+ * In test mode the message is marked `[SANDBOX]` and external recipients are suppressed.
1529
+ */ class EmailAccessor {
1530
+ constructor(transport){
1531
+ this.transport = transport;
1532
+ }
1533
+ async send(input) {
1534
+ const result = await this.transport.post('/api/v1/data/comms/send', input);
1535
+ if ('error' in result) return result;
1536
+ return {
1537
+ data: result.data
1538
+ };
1539
+ }
1540
+ }
1541
+
1542
+ function transform(raw) {
1543
+ return {
1544
+ id: raw['id'],
1545
+ employeeId: raw['employee_id'],
1546
+ hourlyCostRate: raw['hourly_cost_rate'] ?? null,
1547
+ monthlyCostRate: raw['monthly_cost_rate'] ?? null,
1548
+ hourlyBillableRate: raw['hourly_billable_rate'] ?? null,
1549
+ monthlyBillableRate: raw['monthly_billable_rate'] ?? null,
1550
+ salary: raw['salary'] ?? null,
1551
+ currencyCode: raw['currency_code'],
1552
+ effectiveFrom: raw['effective_from'],
1553
+ effectiveTo: raw['effective_to'] ?? null
1554
+ };
1555
+ }
1556
+ /**
1557
+ * EmployeeCompensationAccessor — read/write an employee's effective-dated compensation (WS6).
1558
+ * Gated server-side by the `employee-compensation` scope (buckets: `core`, `rates`, `salary`).
1559
+ * `create` closes the prior open record and opens a new one (Nucleus = single comp authority).
1560
+ */ class EmployeeCompensationAccessor {
1561
+ constructor(transport){
1562
+ this.transport = transport;
1563
+ }
1564
+ base(employeeId) {
1565
+ return `/api/v1/data/employee-compensation/employees/${employeeId}/compensation`;
1566
+ }
1567
+ /** Full history, newest first. */ async list(employeeId) {
1568
+ const result = await this.transport.get(this.base(employeeId));
1569
+ if ('error' in result) return result;
1570
+ return {
1571
+ data: (result.data ?? []).map((r)=>transform(r))
1572
+ };
1573
+ }
1574
+ /** The current (open) record, or null. */ async current(employeeId) {
1575
+ const result = await this.transport.get(`${this.base(employeeId)}/current`);
1576
+ if ('error' in result) return result;
1577
+ return {
1578
+ data: result.data ? transform(result.data) : null
1579
+ };
1580
+ }
1581
+ /** Add a new effective-dated record (closes the prior open one). */ async create(employeeId, data) {
1582
+ const result = await this.transport.post(this.base(employeeId), toSnakeBody(data));
1583
+ if ('error' in result) return result;
1584
+ return {
1585
+ data: transform(result.data)
1586
+ };
1587
+ }
1588
+ /** Soft-delete a compensation record. */ async delete(employeeId, id) {
1589
+ const result = await this.transport.del(`${this.base(employeeId)}/${id}`);
1590
+ if ('error' in result) return result;
1591
+ return {
1592
+ data: null
1593
+ };
1594
+ }
1595
+ }
1596
+
1312
1597
  /**
1313
1598
  * AppsAccessor — wraps the self-service app info endpoint (§3.8, §6).
1314
1599
  * Calls GET /api/v1/platform/apps/me with the app token.
@@ -1463,6 +1748,8 @@ class AuthAccessor {
1463
1748
  this.currencies = new CurrenciesAccessor(transport);
1464
1749
  this.genericRates = new GenericRatesAccessor(transport);
1465
1750
  this.jurisdictions = new JurisdictionsAccessor(transport);
1751
+ this.jobLevels = new JobLevelsAccessor(transport);
1752
+ this.salaryBands = new SalaryBandsAccessor(transport);
1466
1753
  this.leaveTypes = new LeaveTypesAccessor(transport);
1467
1754
  this.leaves = new LeavesAccessor(transport);
1468
1755
  this.leaveBalances = new LeaveBalancesAccessor(transport);
@@ -1470,6 +1757,9 @@ class AuthAccessor {
1470
1757
  this.policyAcknowledgements = new PolicyAcknowledgementsAccessor(transport);
1471
1758
  this.calendarEvents = new CalendarEventsAccessor(transport);
1472
1759
  this.timesheets = new TimesheetsAccessor(transport);
1760
+ this.calendar = new CalendarAccessor(transport);
1761
+ this.email = new EmailAccessor(transport);
1762
+ this.employeeCompensation = new EmployeeCompensationAccessor(transport);
1473
1763
  this.apps = new AppsAccessor(transport);
1474
1764
  this.files = new FilesAccessor(transport);
1475
1765
  this.auth = new AuthAccessor(config.baseUrl);
@@ -1479,7 +1769,7 @@ class AuthAccessor {
1479
1769
  /**
1480
1770
  * Nucleus SDK — Type System
1481
1771
  *
1482
- * Bucket types are transcribed from docs/NUCLEUS_PIVOT.md §7 (Bucket Definitions).
1772
+ * Bucket types are transcribed from docs/ARCHITECTURE.md §7 (Bucket Definitions).
1483
1773
  * Each bucket type contains only that bucket's fields.
1484
1774
  * The scope-to-type inference intersects selected bucket types at compile time.
1485
1775
  */ // ─── Utility types ────────────────────────────────────────────────────────────