@basictech/react 0.2.0-beta.1 → 0.2.0-beta.10

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/src/index.ts CHANGED
@@ -1,6 +1,25 @@
1
1
  import { useBasic, BasicProvider } from "./AuthContext";
2
- import { useLiveQuery as useQuery } from "dexie-react-hooks";
2
+ import { useLiveQuery } from "dexie-react-hooks";
3
+ import { validateSchema, validateData, generateEmptySchema } from "./schema";
4
+ import hello from "@basictech/schema"
5
+
6
+
7
+ function useQuery(queryable: any) {
8
+ return useLiveQuery(() => {
9
+ if (typeof queryable === 'function') {
10
+ return queryable();
11
+ }
12
+ return queryable;
13
+ }, [queryable], []);
14
+ }
15
+
16
+
17
+ const sc = {
18
+ validateSchema: validateSchema,
19
+ validateData: validateData,
20
+ generateEmptySchema: generateEmptySchema
21
+ }
3
22
 
4
23
  export {
5
- useBasic, BasicProvider, useQuery
6
- }
24
+ useBasic, BasicProvider, useQuery, sc, hello
25
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Basic Schema Library
2
+ // utils for validating and interacting with Basic schemas
3
+ import Ajv, { ErrorObject } from 'ajv'
4
+
5
+ const basicJsonSchema = {
6
+ "$schema": "http://json-schema.org/draft-07/schema#",
7
+ "type": "object",
8
+ "properties": {
9
+ "project_id": {
10
+ "type": "string"
11
+ },
12
+ "namespace": {
13
+ "type": "string",
14
+ },
15
+ "version": {
16
+ "type": "integer",
17
+ "minimum": 0
18
+ },
19
+ "tables": {
20
+ "type": "object",
21
+ "patternProperties": {
22
+ "^[a-zA-Z0-9_]+$": {
23
+ "type": "object",
24
+ "properties": {
25
+ "name": {
26
+ "type": "string"
27
+ },
28
+ "type": {
29
+ "type": "string",
30
+ "enum": ["collection"]
31
+ },
32
+ "fields": {
33
+ "type": "object",
34
+ "patternProperties": {
35
+ "^[a-zA-Z0-9_]+$": {
36
+ "type": "object",
37
+ "properties": {
38
+ "type": {
39
+ "type": "string",
40
+ "enum": ["string", "boolean", "number", "json"]
41
+ },
42
+ "indexed": {
43
+ "type": "boolean"
44
+ },
45
+ "required": {
46
+ "type": "boolean"
47
+ }
48
+ },
49
+ "required": ["type"]
50
+ }
51
+ },
52
+ "additionalProperties": true
53
+ }
54
+ },
55
+ "required": ["fields"]
56
+ }
57
+ },
58
+ "additionalProperties": true
59
+ }
60
+ },
61
+ "required": ["project_id", "version", "tables"]
62
+ }
63
+ const ajv = new Ajv()
64
+ const validator = ajv.compile(basicJsonSchema)
65
+
66
+ type Schema = typeof basicJsonSchema
67
+
68
+ function generateEmptySchema() {
69
+
70
+ }
71
+
72
+
73
+ /**
74
+ * Validate a schema
75
+ * only checks if the schema is formatted correctly, not if can be published
76
+ * @param schema - The schema to validate
77
+ * @returns {valid: boolean, errors: any[]} - The validation result
78
+ */
79
+ function validateSchema(schema: Schema) : {valid: boolean, errors: ErrorObject[]} {
80
+ const v = validator(schema)
81
+ return {
82
+ valid: v,
83
+ errors: validator.errors || []
84
+ }
85
+ }
86
+
87
+ // type ErrorObject = {
88
+ // keyword: string;
89
+ // instancePath: string;
90
+ // schemaPath: string;
91
+ // params: Record<string, any>;
92
+ // propertyName?: string;
93
+ // message?: string;
94
+ // schema?: any;
95
+ // parentSchema?: any;
96
+ // data?: any;
97
+ // }
98
+
99
+
100
+ function validateData(schema: any, table: string, data: Record<string, any>, checkRequired: boolean = true) {
101
+ const valid = validateSchema(schema)
102
+ if (!valid.valid) {
103
+ return { valid: false, errors: valid.errors, message: "Schema is invalid" }
104
+ }
105
+
106
+ const tableSchema = schema.tables[table]
107
+
108
+ if (!tableSchema) {
109
+ return { valid: false, errors: [{ message: `Table ${table} not found in schema` }], message: "Table not found" }
110
+ }
111
+
112
+ for (const [fieldName, fieldValue] of Object.entries(data)) {
113
+ const fieldSchema = tableSchema.fields[fieldName]
114
+
115
+ if (!fieldSchema) {
116
+ return {
117
+ valid: false,
118
+ errors: [{ message: `Field ${fieldName} not found in schema` }],
119
+ message: "Invalid field"
120
+ }
121
+ }
122
+
123
+ const schemaType = fieldSchema.type
124
+ const valueType = typeof fieldValue
125
+
126
+ if (
127
+ (schemaType === 'string' && valueType !== 'string') ||
128
+ (schemaType === 'number' && valueType !== 'number') ||
129
+ (schemaType === 'boolean' && valueType !== 'boolean') ||
130
+ (schemaType === 'json' && valueType !== 'object')
131
+ ) {
132
+ return {
133
+ valid: false,
134
+ errors: [{
135
+ message: `Field ${fieldName} should be type ${schemaType}, got ${valueType}`
136
+ }],
137
+ message: "invalid type"
138
+ }
139
+ }
140
+ }
141
+
142
+ if (checkRequired) {
143
+ for (const [fieldName, fieldSchema] of Object.entries(tableSchema.fields)) {
144
+ if ((fieldSchema as { required?: boolean }).required && !data[fieldName]) {
145
+ return { valid: false, errors: [{ message: `Field ${fieldName} is required` }], message: "Required field missing" }
146
+ }
147
+ }
148
+ }
149
+
150
+ return { valid: true, errors: [] }
151
+ }
152
+
153
+
154
+ export {
155
+ validateSchema,
156
+ validateData,
157
+ generateEmptySchema
158
+ }
159
+
@@ -0,0 +1,223 @@
1
+ "use client"
2
+
3
+ import { v7 as uuidv7 } from 'uuid';
4
+ import { Dexie, PromiseExtended } from 'dexie';
5
+ import 'dexie-syncable';
6
+ import 'dexie-observable';
7
+
8
+ import { syncProtocol } from './syncProtocol'
9
+ import { SERVER_URL, log } from '../config'
10
+
11
+ import { validateSchema, validateData } from '../schema'
12
+ syncProtocol()
13
+
14
+
15
+ // const DexieSyncStatus = {
16
+ // "-1": "ERROR",
17
+ // "0": "OFFLINE",
18
+ // "1": "CONNECTING",
19
+ // "2": "ONLINE",
20
+ // "3": "SYNCING",
21
+ // "4": "ERROR_WILL_RETRY"
22
+ // }
23
+
24
+
25
+
26
+
27
+
28
+ export class BasicSync extends Dexie {
29
+ basic_schema: any
30
+
31
+ constructor(name: string, options: any) {
32
+ super(name, options);
33
+
34
+ // --- INIT SCHEMA --- //
35
+
36
+ //todo: handle versions?
37
+
38
+ // TODO: validate schema
39
+ this.basic_schema = options.schema
40
+ this.version(1).stores(this._convertSchemaToDxSchema(this.basic_schema))
41
+
42
+ this.version(2).stores({})
43
+ // this.verssion
44
+
45
+
46
+ // create an alias for toArray
47
+ // @ts-ignore
48
+ this.Collection.prototype.get = this.Collection.prototype.toArray
49
+
50
+
51
+ // --- SYNC --- //
52
+
53
+ // this.syncable.on("statusChanged", (status, url) => {
54
+ // console.log("statusChanged", status, url)
55
+ // })
56
+
57
+ }
58
+
59
+ async connect({ access_token }: { access_token: string }) {
60
+ // const WS_URL = "ws://localhost:3003/ws"
61
+ const WS_URL = `${SERVER_URL}/ws`
62
+
63
+
64
+ // Update sync nodes
65
+ await this.updateSyncNodes();
66
+
67
+ // Proceed with the WebSocket connection
68
+
69
+ log('Starting connection...')
70
+ return this.syncable.connect("websocket", WS_URL, { authToken: access_token });
71
+ }
72
+
73
+ async disconnect() {
74
+ const WS_URL = `${SERVER_URL}/ws`
75
+
76
+ return this.syncable.disconnect(WS_URL)
77
+ }
78
+
79
+ private async updateSyncNodes() {
80
+ try {
81
+ const syncNodes = await this.table('_syncNodes').toArray();
82
+ const localSyncNodes = syncNodes.filter(node => node.type === 'local');
83
+ log('Local sync nodes:', localSyncNodes);
84
+
85
+ if (localSyncNodes.length > 1) {
86
+
87
+
88
+ const largestNodeId = Math.max(...localSyncNodes.map(node => node.id));
89
+ // Check if the largest node is already the master
90
+ const largestNode = localSyncNodes.find(node => node.id === largestNodeId);
91
+ if (largestNode && largestNode.isMaster === 1) {
92
+ log('Largest node is already the master. No changes needed.');
93
+ return; // Exit the function early as no changes are needed
94
+ }
95
+
96
+
97
+ log('Largest node id:', largestNodeId);
98
+ log('HEISENBUG: More than one local sync node found.')
99
+
100
+ for (const node of localSyncNodes) {
101
+ log(`Local sync node keys:`, node.id, node.isMaster);
102
+ await this.table('_syncNodes').update(node.id, { isMaster: node.id === largestNodeId ? 1 : 0 });
103
+
104
+ log(`HEISENBUG: Setting ${node.id} to ${node.id === largestNodeId ? 'master' : '0'}`);
105
+ }
106
+
107
+ // Add a 1 second delay before returning // i dont think this helps?
108
+ await new Promise(resolve => setTimeout(resolve, 2000));
109
+
110
+ }
111
+
112
+ log('Sync nodes updated');
113
+ } catch (error) {
114
+ console.error('Error updating _syncNodes table:', error);
115
+ }
116
+ }
117
+
118
+ handleStatusChange(fn: any) {
119
+ this.syncable.on("statusChanged", fn)
120
+ }
121
+
122
+
123
+ _convertSchemaToDxSchema(schema: any) {
124
+ const stores = Object.entries(schema.tables).map(([key, table]: any) => {
125
+
126
+ const indexedFields = Object.entries(table.fields).filter(([key, field]: any) => field.indexed).map(([key, field]: any) => `,${key}`).join('')
127
+ return {
128
+ [key]: 'id' + indexedFields
129
+ }
130
+ })
131
+
132
+ return Object.assign({}, ...stores)
133
+ }
134
+
135
+ debugeroo() {
136
+ // console.log("debugeroo", this.syncable)
137
+
138
+ // this.syncable.list().then(x => console.log(x))
139
+
140
+ // this.syncable
141
+ return this.syncable
142
+ }
143
+
144
+ collection(name: string) {
145
+ // TODO: check against schema
146
+
147
+ return {
148
+
149
+ /**
150
+ * Returns the underlying Dexie table
151
+ * @type {Dexie.Table}
152
+ */
153
+ ref: this.table(name),
154
+
155
+ // --- WRITE ---- //
156
+ add: (data: any) => {
157
+ // log("Adding data to", name, data)
158
+
159
+ const valid = validateData(this.basic_schema, name, data)
160
+ if (!valid.valid) {
161
+ log('Invalid data', valid)
162
+ return Promise.reject({ ... valid })
163
+ }
164
+
165
+ return this.table(name).add({
166
+ id: uuidv7(),
167
+ ...data
168
+ })
169
+
170
+ },
171
+
172
+ put: (data: any) => {
173
+ const valid = validateData(this.basic_schema, name, data)
174
+ if (!valid.valid) {
175
+ log('Invalid data', valid)
176
+ return Promise.reject({ ... valid })
177
+ }
178
+
179
+ return this.table(name).put({
180
+ id: uuidv7(),
181
+ ...data
182
+ })
183
+ },
184
+
185
+ update: (id: string, data: any) => {
186
+ const valid = validateData(this.basic_schema, name, data, false)
187
+ if (!valid.valid) {
188
+ log('Invalid data', valid)
189
+ return Promise.reject({ ... valid })
190
+ }
191
+
192
+ return this.table(name).update(id, data)
193
+ },
194
+
195
+ delete: (id: string) => {
196
+ return this.table(name).delete(id)
197
+ },
198
+
199
+
200
+ // --- READ ---- //
201
+
202
+ get: async (id: string) => {
203
+ return this.table(name).get(id)
204
+ },
205
+
206
+ getAll: async () => {
207
+ return this.table(name).toArray();
208
+ },
209
+
210
+ // --- QUERY ---- //
211
+ // TODO: lots to do here. simplifing creating querie, filtering/ordering/limit, and execute
212
+
213
+ query: () => this.table(name),
214
+
215
+ filter: (fn: any) => this.table(name).filter(fn).toArray(),
216
+
217
+ }
218
+ }
219
+ }
220
+
221
+ class QueryMethod {
222
+
223
+ }
@@ -0,0 +1,179 @@
1
+ "use client"
2
+ import { Dexie } from "dexie";
3
+ import { log } from "../config";
4
+
5
+ export const syncProtocol = function () {
6
+ log("Initializing syncProtocol");
7
+ // Constants:
8
+ var RECONNECT_DELAY = 5000; // Reconnect delay in case of errors such as network down.
9
+
10
+ Dexie.Syncable.registerSyncProtocol("websocket", {
11
+ sync: function (
12
+ context,
13
+ url,
14
+ options,
15
+ baseRevision,
16
+ syncedRevision,
17
+ changes,
18
+ partial,
19
+ applyRemoteChanges,
20
+ onChangesAccepted,
21
+ onSuccess,
22
+ onError,
23
+ ) {
24
+ // The following vars are needed because we must know which callback to ack when server sends it's ack to us.
25
+ var requestId = 0;
26
+ var acceptCallbacks = {};
27
+
28
+ // Connect the WebSocket to given url:
29
+ var ws = new WebSocket(url);
30
+
31
+ // console.log("ws OPTIONS", options);
32
+
33
+ // sendChanges() method:
34
+ function sendChanges(changes, baseRevision, partial, onChangesAccepted) {
35
+ log("sendChanges", changes.length, baseRevision);
36
+ ++requestId;
37
+ acceptCallbacks[requestId.toString()] = onChangesAccepted;
38
+
39
+ // In this example, the server expects the following JSON format of the request:
40
+ // {
41
+ // type: "changes"
42
+ // baseRevision: baseRevision,
43
+ // changes: changes,
44
+ // partial: partial,
45
+ // requestId: id
46
+ // }
47
+ // To make the sample simplified, we assume the server has the exact same specification of how changes are structured.
48
+ // In real world, you would have to pre-process the changes array to fit the server specification.
49
+ // However, this example shows how to deal with the WebSocket to fullfill the API.
50
+
51
+ ws.send(
52
+ JSON.stringify({
53
+ type: "changes",
54
+ changes: changes,
55
+ partial: partial,
56
+ baseRevision: baseRevision,
57
+ requestId: requestId,
58
+ }),
59
+ );
60
+ }
61
+
62
+
63
+
64
+ // When WebSocket opens, send our changes to the server.
65
+ ws.onopen = function (event) {
66
+ // Initiate this socket connection by sending our clientIdentity. If we dont have a clientIdentity yet,
67
+ // server will call back with a new client identity that we should use in future WebSocket connections.
68
+
69
+ log("Opening socket - sending clientIdentity", context.clientIdentity);
70
+ ws.send(
71
+ JSON.stringify({
72
+ type: "clientIdentity",
73
+ clientIdentity: context.clientIdentity || null,
74
+ authToken: options.authToken
75
+ }),
76
+ );
77
+
78
+ };
79
+
80
+ // If network down or other error, tell the framework to reconnect again in some time:
81
+ ws.onerror = function (event) {
82
+ ws.close();
83
+ log("ws.onerror", event);
84
+ onError(event?.message, RECONNECT_DELAY);
85
+ };
86
+
87
+ // If socket is closed (network disconnected), inform framework and make it reconnect
88
+ ws.onclose = function (event) {
89
+ // console.log('🙅 ws.onclose', event)
90
+ onError("Socket closed: " + event.reason, RECONNECT_DELAY);
91
+ };
92
+
93
+ // isFirstRound: Will need to call onSuccess() only when we are in sync the first time.
94
+ // onSuccess() will unblock Dexie to be used by application code.
95
+ // If for example app code writes: db.friends.where('shoeSize').above(40).toArray(callback), the execution of that query
96
+ // will not run until we have called onSuccess(). This is because we want application code to get results that are as
97
+ // accurate as possible. Specifically when connected the first time and the entire DB is being synced down to the browser,
98
+ // it is important that queries starts running first when db is in sync.
99
+ var isFirstRound = true;
100
+ // When message arrive from the server, deal with the message accordingly:
101
+ ws.onmessage = function (event) {
102
+ try {
103
+ // Assume we have a server that should send JSON messages of the following format:
104
+ // {
105
+ // type: "clientIdentity", "changes", "ack" or "error"
106
+ // clientIdentity: unique value for our database client node to persist in the context. (Only applicable if type="clientIdentity")
107
+ // message: Error message (Only applicable if type="error")
108
+ // requestId: ID of change request that is acked by the server (Only applicable if type="ack" or "error")
109
+ // changes: changes from server (Only applicable if type="changes")
110
+ // lastRevision: last revision of changes sent (applicable if type="changes")
111
+ // partial: true if server has additionalChanges to send. False if these changes were the last known. (applicable if type="changes")
112
+ // }
113
+ var requestFromServer = JSON.parse(event.data);
114
+ log("requestFromServer", requestFromServer, { acceptCallback, isFirstRound });
115
+
116
+ if (requestFromServer.type == "clientIdentity") {
117
+ context.clientIdentity = requestFromServer.clientIdentity;
118
+ context.save();
119
+
120
+ sendChanges(changes, baseRevision, partial, onChangesAccepted);
121
+
122
+ ws.send(
123
+ JSON.stringify({
124
+ type: "subscribe",
125
+ syncedRevision: syncedRevision,
126
+ }),
127
+ );
128
+ } else if (requestFromServer.type == "changes") {
129
+ applyRemoteChanges(
130
+ requestFromServer.changes,
131
+ requestFromServer.currentRevision,
132
+ requestFromServer.partial,
133
+ );
134
+ if (isFirstRound && !requestFromServer.partial) {
135
+ // Since this is the first sync round and server sais we've got all changes - now is the time to call onsuccess()
136
+ onSuccess({
137
+ // Specify a react function that will react on additional client changes
138
+ react: function (
139
+ changes,
140
+ baseRevision,
141
+ partial,
142
+ onChangesAccepted,
143
+ ) {
144
+ sendChanges(
145
+ changes,
146
+ baseRevision,
147
+ partial,
148
+ onChangesAccepted,
149
+ );
150
+ },
151
+ // Specify a disconnect function that will close our socket so that we dont continue to monitor changes.
152
+ disconnect: function () {
153
+ ws.close();
154
+ },
155
+ });
156
+ isFirstRound = false;
157
+ }
158
+ } else if (requestFromServer.type == "ack") {
159
+ var requestId = requestFromServer.requestId;
160
+ var acceptCallback = acceptCallbacks[requestId.toString()];
161
+ acceptCallback(); // Tell framework that server has acknowledged the changes sent.
162
+ delete acceptCallbacks[requestId.toString()];
163
+ } else if (requestFromServer.type == "error") {
164
+ var requestId = requestFromServer.requestId;
165
+ ws.close();
166
+ onError(requestFromServer.message, Infinity); // Don't reconnect - an error in application level means we have done something wrong.
167
+ } else {
168
+ log("unknown message", requestFromServer);
169
+ ws.close();
170
+ onError("unknown message", Infinity);
171
+ }
172
+ } catch (e) {
173
+ ws.close();
174
+ onError(e, Infinity); // Something went crazy. Server sends invalid format or our code is buggy. Dont reconnect - it would continue failing.
175
+ }
176
+ };
177
+ },
178
+ });
179
+ };