saasufy-components 8.3.1 → 8.4.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
@@ -83,7 +83,7 @@ This project does not impose any specific approach to styling components, howeve
83
83
  <link href="https://saasufy.com/node_modules/saasufy-components/styles.css" rel="stylesheet" />
84
84
  ```
85
85
 
86
- ### Utility functions
86
+ ### Template utility functions
87
87
 
88
88
  The following utility functions can be used anywhere inside template `{{expression}}` placeholders:
89
89
 
@@ -99,6 +99,65 @@ The following utility functions can be used anywhere inside template `{{expressi
99
99
  - Make a string safe by escaping HTML characters: `{{safeString(Product.name)}}`
100
100
  - Given an array of objects, extract the values from the specified field and join them together into a single comma-separated string: `{{joinFields(Product, 'name')}}`
101
101
 
102
+ ### General utility functions
103
+
104
+ The following utility functions are available for advanced use cases and can be imported from the `saasufy-components` module:
105
+
106
+ **Import from CDN:**
107
+ ```javascript
108
+ import {
109
+ getRecordIds,
110
+ getRecord,
111
+ getRecordCount,
112
+ generateRecords,
113
+ getRecords,
114
+ logAttributeChanges,
115
+ wait
116
+ } from 'https://saasufy.com/node_modules/saasufy-components/utils.js';
117
+ ```
118
+
119
+ **Import from npm:**
120
+ ```javascript
121
+ import {
122
+ getRecordIds,
123
+ getRecord,
124
+ getRecordCount,
125
+ generateRecords,
126
+ getRecords,
127
+ logAttributeChanges,
128
+ wait
129
+ } from 'saasufy-components/utils.js';
130
+ ```
131
+
132
+ **Available functions:**
133
+
134
+ - `getRecordIds({ socket, type, viewName, viewParams, startPage, maxPages, pageSize })`: Retrieves record IDs from a collection view across multiple pages. Returns an array of IDs.
135
+ - `getRecord({ socket, type, id, fields })`: Fetches a single record by ID. If fields are specified, only those fields are retrieved; otherwise the entire record is returned.
136
+ - `getRecordCount({ socket, type, viewName, viewParams, offset })`: Gets the total count of records in a view. Returns a number representing the count.
137
+ - `generateRecords({ socket, type, viewName, viewParams, fields, startPage, pageSize, maxAttempts })`: An async generator function that yields records one by one from a collection view. Useful for processing large datasets efficiently.
138
+
139
+ **Example usage:**
140
+ ```javascript
141
+ // Process all products one by one without loading them all into memory at once
142
+ const productGenerator = generateRecords({
143
+ socket: mySocket,
144
+ type: 'Product',
145
+ viewName: 'alphabeticalView',
146
+ viewParams: { category: 'electronics' },
147
+ fields: [ 'name', 'price', 'qty' ],
148
+ pageSize: 50
149
+ });
150
+
151
+ for await (let product of productGenerator) {
152
+ console.log(`Processing product: ${product.name}`);
153
+ // Process each product individually
154
+ }
155
+ ```
156
+ - `getRecords({ socket, type, viewName, viewParams, fields, startPage, maxPages, pageSize })`: Retrieves multiple records from a collection view. Returns an array of record objects.
157
+ - `logAttributeChanges(...elementSelectors)`: Logs attribute changes for DOM elements to the console. Useful for debugging. Returns a function to stop observing.
158
+ - `wait(duration)`: Creates a promise that resolves after the specified duration in milliseconds. Useful for adding delays in async operations.
159
+
160
+
102
161
  ### Special fields
103
162
 
104
163
  Saasufy supports a number of special metadata fields which are automatically created and updated on each model record.
@@ -120,6 +179,17 @@ You will need to deploy your Saasufy service from the dashboard for the changes
120
179
  The `id` field in Saasufy is extra special; you can optionally specify it when you create a new resource, however, if it is not specified, Saasufy will create one for you.
121
180
  Saasufy offers utility functions for creating IDs (random and deterministic). See the previous `Utility functions` section.
122
181
 
182
+ ### Events
183
+
184
+ The Saasufy `collection-viewer` component dispatches the following events when records are created, updated or removed from an active collection as a result of user interactions with that component:
185
+
186
+ - `collectionCreate`
187
+ - `collectionUpdate`
188
+ - `collectionDelete`
189
+
190
+ These events are set to bubble so you can listen to them on any parent element in the DOM.
191
+ The event object passed to the listener function has a `detail` property which is an object with a `type` and `id` property to indicate the type and id of the affected resource within the collection.
192
+
123
193
  ### File hosting
124
194
 
125
195
  Saasufy provides basic HTTP/HTTPS file hosting functionality with support for client-side caching (with `ETag`).
@@ -0,0 +1,181 @@
1
+ # WebSocket API
2
+
3
+ ## Overview
4
+
5
+ Saasufy exposes a powerful WebSocket API which allows you to integrate with your Saasufy service in complex ways; it supports CRUD operations and subscriptions for data changes. This API can be used to build custom components which interact with Saasufy data or to integrate with third-party services.
6
+
7
+ The Saasufy WebSocket CRUD API and Subscribe API are implemented on top of the SocketCluster protocol.
8
+ It is recommended to use the `socketcluster-client` library: http://npmjs.org/socketcluster-client
9
+
10
+ You can connect a SocketCluster client socket to your Saasufy service like this:
11
+ ```js
12
+ // The the script URL/path may differ depending on your setup
13
+ import {
14
+ create as createSocket
15
+ } from 'https://saasufy.com/node_modules/socketcluster-client/socketcluster-client.min.js';
16
+
17
+ // Change the path with that of your own service
18
+ let clientSocket = createSocket({
19
+ hostname: 'saasufy.com',
20
+ port: 443,
21
+ secure: true,
22
+ path: '/sid8001/socketcluster/'
23
+ });
24
+ ```
25
+
26
+ ## CRUD API
27
+
28
+ All CRUD operations can be invoked on a client socket like this:
29
+
30
+ ```js
31
+ await clientSocket.invoke('crud', queryObject);
32
+ ```
33
+
34
+ The format of the `queryObject` for various operations is shown below.
35
+
36
+ ### Read collection view
37
+ ```json
38
+ {
39
+ "action": "read",
40
+ "type": "Product",
41
+ "offset": 0,
42
+ "view": "categoryView",
43
+ "viewParams": {
44
+ "categoryName": "Electronics"
45
+ },
46
+ "pageSize": 10
47
+ }
48
+ ```
49
+ This API returns a response object with `data` and `isLastPage` properties. The `data` property holds an array of IDs - The recommended approach is to load the list of IDs and then fetch individual properties on demand. The Saasufy service uses lightweight WebSocket frames and caches resources on the back end so it's acceptable to make a relatively large number of requests for individual fields.
50
+
51
+ If `getCount` is true, the operation will return the total number of records which exist in the specified view via a `count` property. Note that this can become computationally expensive if the view has a large number of records.
52
+
53
+ ### Create model instance
54
+ ```json
55
+ {
56
+ "action": "create",
57
+ "type": "Product",
58
+ "value": {
59
+ "name": "Pixel 7",
60
+ "brand": "Google",
61
+ "categoryName": "Electronics"
62
+ }
63
+ }
64
+ ```
65
+ An optional `id` field can be specified as part of the `value` object. If it is not specified, then the `id` will be created automatically by Saasufy.
66
+
67
+ ### Update model instance
68
+ ```json
69
+ {
70
+ "action": "update",
71
+ "type": "Product",
72
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92",
73
+ "value": {
74
+ "name": "Pixel 7"
75
+ }
76
+ }
77
+ ```
78
+ By default, for efficiency reasons, change notifications related to a specific CRUD action are not sent to the socket which initiated the action. In order to receive such notifications, you should add a top level `publisherId` property to the object. It should be a string of length between 1 and 50 characters. The `publisherId` will be sent to the origin client socket alongside the change notification. It can be used to identify where the action originated.
79
+
80
+ ### Delete model instance
81
+ ```json
82
+ {
83
+ "action": "delete",
84
+ "type": "Product",
85
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92"
86
+ }
87
+ ```
88
+ In order to receive change notifications back from the client socket which initiated an action, you should add a top level `publisherId` property to the object above. It should be a string of length between 1 and 50 characters. The `publisherId` will be sent to the origin client socket alongside the change notification.
89
+
90
+ ### Read model field
91
+ ```json
92
+ {
93
+ "action": "read",
94
+ "type": "Product",
95
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92",
96
+ "field": "name"
97
+ }
98
+ ```
99
+ The `field` property can be omitted to read the entire resource object with all available fields.
100
+
101
+ ### Update model field
102
+ ```json
103
+ {
104
+ "action": "update",
105
+ "type": "Product",
106
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92",
107
+ "field": "name",
108
+ "value": "Pixel 7"
109
+ }
110
+ ```
111
+ In order to receive change notifications back from the client socket which initiated an action, you should add a top level `publisherId` property to the object above. It should be a string of length between 1 and 50 characters. The `publisherId` will be sent to the origin client socket alongside the change notification.
112
+
113
+ ### Update multiple model fields
114
+ ```json
115
+ {
116
+ "action": "update",
117
+ "type": "Product",
118
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92",
119
+ "value": {
120
+ "name": "Pixel 7",
121
+ "qty": 3
122
+ }
123
+ }
124
+ ```
125
+
126
+ ### Delete model field
127
+ ```json
128
+ {
129
+ "action": "delete",
130
+ "type": "Product",
131
+ "id": "2201ec45-1c7c-4a5f-8432-1015b7fc4b92",
132
+ "field": "qty"
133
+ }
134
+ ```
135
+
136
+ ## Subscribe API
137
+
138
+ All subscribe operations can be invoked on a client socket like this:
139
+
140
+ ```js
141
+ let channel = clientSocket.subscribe(channelName);
142
+ ```
143
+
144
+ Changes can be consumed like this:
145
+
146
+ ```js
147
+ for await (let data of channel) {
148
+ // data represents the change object.
149
+ }
150
+ ```
151
+
152
+ ### View changes
153
+
154
+ The format of the `channelName` is this:
155
+
156
+ ```js
157
+ `crud>${viewName}(${viewPrimaryParamsString}):${modelType}`
158
+ ```
159
+
160
+ For example, to subscribe to changes to the `categoryView` of the `Product` collection, with the view params `{ categoryName: 'Electronics' }`, the `channelName` to use would be:
161
+
162
+ ```js
163
+ 'crud>categoryView({"categoryName":"Electronics"}):Product'
164
+ ```
165
+
166
+ If there are multiple view params, they must be specified in alphabetical order based on the property name.
167
+
168
+ ### Field changes
169
+
170
+ The format of the `channelName` is this:
171
+
172
+ ```js
173
+ `crud>${modelType}/${modelId}/${modelField}`
174
+ ```
175
+
176
+ For example, to subscribe to changes to the `name` field of a `Product` with ID `a3647d32-1aa2-4332-b8bb-8f3c84648cfa`, the `channelName` to use would be:
177
+
178
+ ```js
179
+ 'crud>Product/a3647d32-1aa2-4332-b8bb-8f3c84648cfa/name'
180
+ ```
181
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saasufy-components",
3
- "version": "8.3.1",
3
+ "version": "8.4.1",
4
4
  "description": "Components for Saasufy.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/utils.js CHANGED
@@ -510,6 +510,134 @@ export function formatError(error) {
510
510
  return error.code === 1009 ? 'Resource exceeded the maximum size' : error.message;
511
511
  }
512
512
 
513
+ export function getBatches(list, batchSize) {
514
+ let batches = [];
515
+ let currentBatch = [];
516
+ for (let item of list) {
517
+ if (currentBatch.length >= batchSize) {
518
+ batches.push(currentBatch);
519
+ currentBatch = [];
520
+ }
521
+ currentBatch.push(item);
522
+ }
523
+ if (currentBatch.length) {
524
+ batches.push(currentBatch);
525
+ }
526
+ return batches;
527
+ }
528
+
529
+ export async function getRecordIds({ socket, type, viewName, viewParams, startPage, maxPages, pageSize }) {
530
+ if (startPage == null) startPage = 0;
531
+ if (maxPages == null) maxPages = 10;
532
+ if (pageSize == null) pageSize = 100;
533
+ let currentPage = startPage;
534
+ let lastPage = currentPage + maxPages;
535
+ let isLastPage = false;
536
+ let modelIds = [];
537
+ while (!isLastPage && currentPage < lastPage) {
538
+ let result = await socket.invoke('crud', {
539
+ action: 'read',
540
+ type,
541
+ offset: currentPage * pageSize,
542
+ view: viewName,
543
+ viewParams,
544
+ pageSize
545
+ });
546
+ isLastPage = result.isLastPage;
547
+ modelIds.push(...result.data);
548
+ currentPage++;
549
+ }
550
+ return modelIds;
551
+ }
552
+
553
+ export async function getRecord({ socket, type, id, fields }) {
554
+ if (!fields) {
555
+ return socket.invoke('crud', {
556
+ action: 'read',
557
+ type,
558
+ id
559
+ });
560
+ }
561
+ let data = await Promise.all(
562
+ fields.map(async (field) => {
563
+ return socket.invoke('crud', {
564
+ action: 'read',
565
+ type,
566
+ id,
567
+ field
568
+ });
569
+ })
570
+ );
571
+ let fieldCount = fields.length;
572
+ let record = {};
573
+ for (let i = 0; i < fieldCount; i++) {
574
+ record[fields[i]] = data[i];
575
+ }
576
+ return record;
577
+ }
578
+
579
+ export async function getRecordCount({ socket, type, viewName, viewParams, offset }) {
580
+ let result = await socket.invoke('crud', {
581
+ action: 'read',
582
+ type,
583
+ offset: offset || 0,
584
+ view: viewName,
585
+ viewParams,
586
+ pageSize: 0,
587
+ getCount: true
588
+ });
589
+ return Math.max(result.count - offset, 0);
590
+ }
591
+
592
+ export async function* generateRecords({ socket, type, viewName, viewParams, fields, startPage, pageSize, maxAttempts }) {
593
+ if (startPage == null) startPage = 0;
594
+ if (pageSize == null) pageSize = 100;
595
+ if (maxAttempts == null) maxAttempts = 10;
596
+
597
+ let currentPage = startPage;
598
+ let isLastPage = false;
599
+ let failureCount = 0;
600
+
601
+ while (!isLastPage) {
602
+ try {
603
+ let result = await socket.invoke('crud', {
604
+ action: 'read',
605
+ type,
606
+ offset: currentPage * pageSize,
607
+ view: viewName,
608
+ viewParams,
609
+ pageSize
610
+ });
611
+ let idList = result.data || [];
612
+ for (let id of idList) {
613
+ yield await getRecord({ socket, type, id, fields });
614
+ }
615
+ isLastPage = result.isLastPage;
616
+ currentPage++;
617
+ failureCount = 0;
618
+ } catch (error) {
619
+ if (++failureCount > maxAttempts) {
620
+ throw new Error(`Record generation failed after max consecutive attempts because of error: ${error.message}`);
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ export async function getRecords({ socket, type, viewName, viewParams, fields, startPage, maxPages, pageSize }) {
627
+ let recordIds = await getRecordIds({ socket, type, viewName, viewParams, startPage, maxPages, pageSize });
628
+ let recordIdBatches = getBatches(recordIds, pageSize);
629
+ let records = [];
630
+ for (let idBatch of recordIdBatches) {
631
+ let recordBatch = await Promise.all(
632
+ idBatch.map(async (id) => {
633
+ return getRecord({ socket, type, id, fields });
634
+ })
635
+ );
636
+ records.push(...recordBatch);
637
+ }
638
+ return records;
639
+ }
640
+
513
641
  export function logAttributeChanges(...elementSelectors) {
514
642
  let domObserverCallback = (mutationList) => {
515
643
  for (let mutation of mutationList) {
@@ -544,4 +672,4 @@ export function logAttributeChanges(...elementSelectors) {
544
672
 
545
673
  export function wait(duration) {
546
674
  return new Promise((resolve) => setTimeout(resolve, duration));
547
- }
675
+ }