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 +71 -1
- package/WEBSOCKET_API.md +181 -0
- package/package.json +1 -1
- package/utils.js +129 -1
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
|
-
###
|
|
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`).
|
package/WEBSOCKET_API.md
ADDED
|
@@ -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
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
|
+
}
|