;
13 | };
14 | export default ArSyncApi;
15 |
--------------------------------------------------------------------------------
/core/ArSyncApi.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var __assign = (this && this.__assign) || function () {
3 | __assign = Object.assign || function(t) {
4 | for (var s, i = 1, n = arguments.length; i < n; i++) {
5 | s = arguments[i];
6 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7 | t[p] = s[p];
8 | }
9 | return t;
10 | };
11 | return __assign.apply(this, arguments);
12 | };
13 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
14 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
15 | return new (P || (P = Promise))(function (resolve, reject) {
16 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
17 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
18 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
19 | step((generator = generator.apply(thisArg, _arguments || [])).next());
20 | });
21 | };
22 | var __generator = (this && this.__generator) || function (thisArg, body) {
23 | var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
24 | return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
25 | function verb(n) { return function (v) { return step([n, v]); }; }
26 | function step(op) {
27 | if (f) throw new TypeError("Generator is already executing.");
28 | while (_) try {
29 | if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
30 | if (y = 0, t) op = [op[0] & 2, t.value];
31 | switch (op[0]) {
32 | case 0: case 1: t = op; break;
33 | case 4: _.label++; return { value: op[1], done: false };
34 | case 5: _.label++; y = op[1]; op = [0]; continue;
35 | case 7: op = _.ops.pop(); _.trys.pop(); continue;
36 | default:
37 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
38 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
39 | if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
40 | if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
41 | if (t[2]) _.ops.pop();
42 | _.trys.pop(); continue;
43 | }
44 | op = body.call(thisArg, _);
45 | } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
46 | if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
47 | }
48 | };
49 | Object.defineProperty(exports, "__esModule", { value: true });
50 | function apiBatchFetch(endpoint, requests) {
51 | return __awaiter(this, void 0, void 0, function () {
52 | var headers, body, option, res;
53 | return __generator(this, function (_a) {
54 | switch (_a.label) {
55 | case 0:
56 | headers = {
57 | 'Accept': 'application/json',
58 | 'Content-Type': 'application/json'
59 | };
60 | body = JSON.stringify({ requests: requests });
61 | option = { credentials: 'include', method: 'POST', headers: headers, body: body };
62 | if (ArSyncApi.domain)
63 | endpoint = ArSyncApi.domain + endpoint;
64 | return [4 /*yield*/, fetch(endpoint, option)];
65 | case 1:
66 | res = _a.sent();
67 | if (res.status === 200)
68 | return [2 /*return*/, res.json()];
69 | throw new Error(res.statusText);
70 | }
71 | });
72 | });
73 | }
74 | var ApiFetcher = /** @class */ (function () {
75 | function ApiFetcher(endpoint) {
76 | this.batches = [];
77 | this.batchFetchTimer = null;
78 | this.endpoint = endpoint;
79 | }
80 | ApiFetcher.prototype.fetch = function (request) {
81 | var _this = this;
82 | if (request.id != null) {
83 | return new Promise(function (resolve, reject) {
84 | _this.fetch({ api: request.api, params: { ids: [request.id] }, query: request.query }).then(function (result) {
85 | if (result[0])
86 | resolve(result[0]);
87 | else
88 | reject({ type: 'Not Found', retry: false });
89 | }).catch(reject);
90 | });
91 | }
92 | return new Promise(function (resolve, reject) {
93 | _this.batches.push([request, { resolve: resolve, reject: reject }]);
94 | if (_this.batchFetchTimer)
95 | return;
96 | _this.batchFetchTimer = setTimeout(function () {
97 | _this.batchFetchTimer = null;
98 | var compacts = {};
99 | var requests = [];
100 | var callbacksList = [];
101 | for (var _i = 0, _a = _this.batches; _i < _a.length; _i++) {
102 | var batch = _a[_i];
103 | var request_1 = batch[0];
104 | var callback = batch[1];
105 | var key = JSON.stringify(request_1);
106 | if (compacts[key]) {
107 | compacts[key].push(callback);
108 | }
109 | else {
110 | requests.push(request_1);
111 | callbacksList.push(compacts[key] = [callback]);
112 | }
113 | }
114 | _this.batches = [];
115 | ArSyncApi._batchFetch(_this.endpoint, requests).then(function (results) {
116 | for (var i in callbacksList) {
117 | var result = results[i];
118 | var callbacks = callbacksList[i];
119 | for (var _i = 0, callbacks_1 = callbacks; _i < callbacks_1.length; _i++) {
120 | var callback = callbacks_1[_i];
121 | if (result.data !== undefined) {
122 | callback.resolve(result.data);
123 | }
124 | else {
125 | var error = result.error || { type: 'Unknown Error' };
126 | callback.reject(__assign(__assign({}, error), { retry: false }));
127 | }
128 | }
129 | }
130 | }).catch(function (e) {
131 | var error = { type: e.name, message: e.message, retry: true };
132 | for (var _i = 0, callbacksList_1 = callbacksList; _i < callbacksList_1.length; _i++) {
133 | var callbacks = callbacksList_1[_i];
134 | for (var _a = 0, callbacks_2 = callbacks; _a < callbacks_2.length; _a++) {
135 | var callback = callbacks_2[_a];
136 | callback.reject(error);
137 | }
138 | }
139 | });
140 | }, 16);
141 | });
142 | };
143 | return ApiFetcher;
144 | }());
145 | var staticFetcher = new ApiFetcher('/static_api');
146 | var syncFetcher = new ApiFetcher('/sync_api');
147 | var ArSyncApi = {
148 | domain: null,
149 | _batchFetch: apiBatchFetch,
150 | fetch: function (request) { return staticFetcher.fetch(request); },
151 | syncFetch: function (request) { return syncFetcher.fetch(request); },
152 | };
153 | exports.default = ArSyncApi;
154 |
--------------------------------------------------------------------------------
/core/ArSyncModel.d.ts:
--------------------------------------------------------------------------------
1 | import { ArSyncStore, Request } from './ArSyncStore';
2 | import ConnectionAdapter from './ConnectionAdapter';
3 | declare type Path = Readonly<(string | number)[]>;
4 | interface Change {
5 | path: Path;
6 | value: any;
7 | }
8 | declare type ChangeCallback = (change: Change) => void;
9 | declare type LoadCallback = () => void;
10 | declare type ConnectionCallback = (status: boolean) => void;
11 | declare type SubscriptionType = 'load' | 'change' | 'connection' | 'destroy';
12 | declare type SubscriptionCallback = ChangeCallback | LoadCallback | ConnectionCallback;
13 | declare type ArSyncModelRef = {
14 | key: string;
15 | count: number;
16 | timer: number | null;
17 | model: ArSyncStore;
18 | };
19 | declare type PathFirst> = ((...args: P) => void) extends (first: infer First, ...other: any) => void ? First : never;
20 | declare type PathRest = U extends Readonly ? ((...args: U) => any) extends (head: any, ...args: infer T) => any ? U extends Readonly<[any, any, ...any[]]> ? T : never : never : never;
21 | declare type DigResult> = Data extends null | undefined ? Data : PathFirst extends never ? Data : PathFirst
extends keyof Data ? (Data extends Readonly ? undefined : never) | {
22 | 0: Data[PathFirst];
23 | 1: DigResult], PathRest>;
24 | }[PathRest
extends never ? 0 : 1] : undefined;
25 | export default class ArSyncModel {
26 | private _ref;
27 | private _listenerSerial;
28 | private _listeners;
29 | complete: boolean;
30 | notfound?: boolean;
31 | destroyed: boolean;
32 | connected: boolean;
33 | data: T | null;
34 | static _cache: {
35 | [key: string]: {
36 | key: string;
37 | count: number;
38 | timer: number | null;
39 | model: any;
40 | };
41 | };
42 | static cacheTimeout: number;
43 | constructor(request: Request, option?: {
44 | immutable: boolean;
45 | });
46 | onload(callback: LoadCallback): void;
47 | subscribeOnce(event: SubscriptionType, callback: SubscriptionCallback): {
48 | unsubscribe: () => void;
49 | };
50 | dig(path: P): DigResult | null;
51 | static digData(data: Data, path: P): DigResult;
52 | subscribe(event: SubscriptionType, callback: SubscriptionCallback): {
53 | unsubscribe: () => void;
54 | };
55 | release(): void;
56 | static retrieveRef(request: Request, option?: {
57 | immutable: boolean;
58 | }): ArSyncModelRef;
59 | static _detach(ref: any): void;
60 | private static _attach;
61 | static setConnectionAdapter(adapter: ConnectionAdapter): void;
62 | static waitForLoad(...models: ArSyncModel<{}>[]): Promise;
63 | }
64 | export {};
65 |
--------------------------------------------------------------------------------
/core/ArSyncModel.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 | var ArSyncStore_1 = require("./ArSyncStore");
4 | var ConnectionManager_1 = require("./ConnectionManager");
5 | var ArSyncModel = /** @class */ (function () {
6 | function ArSyncModel(request, option) {
7 | var _this = this;
8 | this.complete = false;
9 | this.destroyed = false;
10 | this._ref = ArSyncModel.retrieveRef(request, option);
11 | this._listenerSerial = 0;
12 | this._listeners = {};
13 | this.connected = ArSyncStore_1.ArSyncStore.connectionManager.networkStatus;
14 | var setData = function () {
15 | _this.data = _this._ref.model.data;
16 | _this.complete = _this._ref.model.complete;
17 | _this.notfound = _this._ref.model.notfound;
18 | _this.destroyed = _this._ref.model.destroyed;
19 | };
20 | setData();
21 | this.subscribe('load', setData);
22 | this.subscribe('change', setData);
23 | this.subscribe('destroy', setData);
24 | this.subscribe('connection', function (status) {
25 | _this.connected = status;
26 | });
27 | }
28 | ArSyncModel.prototype.onload = function (callback) {
29 | this.subscribeOnce('load', callback);
30 | };
31 | ArSyncModel.prototype.subscribeOnce = function (event, callback) {
32 | var subscription = this.subscribe(event, function (arg) {
33 | callback(arg);
34 | subscription.unsubscribe();
35 | });
36 | return subscription;
37 | };
38 | ArSyncModel.prototype.dig = function (path) {
39 | return ArSyncModel.digData(this.data, path);
40 | };
41 | ArSyncModel.digData = function (data, path) {
42 | function dig(data, path) {
43 | if (path.length === 0)
44 | return data;
45 | if (data == null)
46 | return data;
47 | var key = path[0];
48 | var other = path.slice(1);
49 | if (Array.isArray(data)) {
50 | return this.digData(data.find(function (el) { return el.id === key; }), other);
51 | }
52 | else {
53 | return this.digData(data[key], other);
54 | }
55 | }
56 | return dig(data, path);
57 | };
58 | ArSyncModel.prototype.subscribe = function (event, callback) {
59 | var _this = this;
60 | var id = this._listenerSerial++;
61 | var subscription = this._ref.model.subscribe(event, callback);
62 | var unsubscribed = false;
63 | var unsubscribe = function () {
64 | unsubscribed = true;
65 | subscription.unsubscribe();
66 | delete _this._listeners[id];
67 | };
68 | if (this.complete) {
69 | if (event === 'load')
70 | setTimeout(function () {
71 | if (!unsubscribed)
72 | callback();
73 | }, 0);
74 | if (event === 'change')
75 | setTimeout(function () {
76 | if (!unsubscribed)
77 | callback({ path: [], value: _this.data });
78 | }, 0);
79 | }
80 | return this._listeners[id] = { unsubscribe: unsubscribe };
81 | };
82 | ArSyncModel.prototype.release = function () {
83 | for (var id in this._listeners)
84 | this._listeners[id].unsubscribe();
85 | this._listeners = {};
86 | ArSyncModel._detach(this._ref);
87 | };
88 | ArSyncModel.retrieveRef = function (request, option) {
89 | var key = JSON.stringify([request, option]);
90 | var ref = this._cache[key];
91 | if (!ref) {
92 | var model = new ArSyncStore_1.ArSyncStore(request, option);
93 | ref = this._cache[key] = { key: key, count: 0, timer: null, model: model };
94 | }
95 | this._attach(ref);
96 | return ref;
97 | };
98 | ArSyncModel._detach = function (ref) {
99 | var _this = this;
100 | ref.count--;
101 | var timeout = this.cacheTimeout;
102 | if (ref.count !== 0)
103 | return;
104 | var timedout = function () {
105 | ref.model.release();
106 | delete _this._cache[ref.key];
107 | };
108 | if (timeout) {
109 | ref.timer = setTimeout(timedout, timeout);
110 | }
111 | else {
112 | timedout();
113 | }
114 | };
115 | ArSyncModel._attach = function (ref) {
116 | ref.count++;
117 | if (ref.timer)
118 | clearTimeout(ref.timer);
119 | };
120 | ArSyncModel.setConnectionAdapter = function (adapter) {
121 | ArSyncStore_1.ArSyncStore.connectionManager = new ConnectionManager_1.default(adapter);
122 | };
123 | ArSyncModel.waitForLoad = function () {
124 | var models = [];
125 | for (var _i = 0; _i < arguments.length; _i++) {
126 | models[_i] = arguments[_i];
127 | }
128 | return new Promise(function (resolve) {
129 | var count = 0;
130 | for (var _i = 0, models_1 = models; _i < models_1.length; _i++) {
131 | var model = models_1[_i];
132 | model.onload(function () {
133 | count++;
134 | if (models.length == count)
135 | resolve(models);
136 | });
137 | }
138 | });
139 | };
140 | ArSyncModel._cache = {};
141 | ArSyncModel.cacheTimeout = 10 * 1000;
142 | return ArSyncModel;
143 | }());
144 | exports.default = ArSyncModel;
145 |
--------------------------------------------------------------------------------
/core/ArSyncStore.d.ts:
--------------------------------------------------------------------------------
1 | export declare type Request = {
2 | api: string;
3 | query: any;
4 | params?: any;
5 | id?: any;
6 | };
7 | export declare class ArSyncStore {
8 | immutable: boolean;
9 | markedForFreezeObjects: any[];
10 | changes: any;
11 | eventListeners: any;
12 | markForRelease: true | undefined;
13 | container: any;
14 | request: Request;
15 | complete: boolean;
16 | notfound?: boolean;
17 | destroyed: boolean;
18 | data: any;
19 | changesBufferTimer: number | undefined | null;
20 | retryLoadTimer: number | undefined | null;
21 | static connectionManager: any;
22 | constructor(request: Request, { immutable }?: {
23 | immutable?: boolean | undefined;
24 | });
25 | handleDestroy(): void;
26 | load(retryCount: number): void;
27 | setChangesBufferTimer(): void;
28 | subscribe(event: any, callback: any): {
29 | unsubscribe: () => void;
30 | };
31 | trigger(event: any, arg?: any): void;
32 | mark(object: any): void;
33 | freezeRecursive(obj: any): any;
34 | freezeMarked(): void;
35 | release(): void;
36 | }
37 |
--------------------------------------------------------------------------------
/core/ArSyncStore.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var __extends = (this && this.__extends) || (function () {
3 | var extendStatics = function (d, b) {
4 | extendStatics = Object.setPrototypeOf ||
5 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
6 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
7 | return extendStatics(d, b);
8 | };
9 | return function (d, b) {
10 | extendStatics(d, b);
11 | function __() { this.constructor = d; }
12 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
13 | };
14 | })();
15 | var __assign = (this && this.__assign) || function () {
16 | __assign = Object.assign || function(t) {
17 | for (var s, i = 1, n = arguments.length; i < n; i++) {
18 | s = arguments[i];
19 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
20 | t[p] = s[p];
21 | }
22 | return t;
23 | };
24 | return __assign.apply(this, arguments);
25 | };
26 | var __spreadArrays = (this && this.__spreadArrays) || function () {
27 | for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;
28 | for (var r = Array(s), k = 0, i = 0; i < il; i++)
29 | for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)
30 | r[k] = a[j];
31 | return r;
32 | };
33 | Object.defineProperty(exports, "__esModule", { value: true });
34 | exports.ArSyncStore = void 0;
35 | var ArSyncApi_1 = require("./ArSyncApi");
36 | var ModelBatchRequest = /** @class */ (function () {
37 | function ModelBatchRequest() {
38 | this.timer = null;
39 | this.apiRequests = new Map();
40 | }
41 | ModelBatchRequest.prototype.fetch = function (api, query, id) {
42 | var _this = this;
43 | this.setTimer();
44 | return new Promise(function (resolve, reject) {
45 | var queryJSON = JSON.stringify(query);
46 | var apiRequest = _this.apiRequests.get(api);
47 | if (!apiRequest)
48 | _this.apiRequests.set(api, apiRequest = new Map());
49 | var queryRequests = apiRequest.get(queryJSON);
50 | if (!queryRequests)
51 | apiRequest.set(queryJSON, queryRequests = { query: query, requests: new Map() });
52 | var request = queryRequests.requests.get(id);
53 | if (!request)
54 | queryRequests.requests.set(id, request = { id: id, callbacks: [] });
55 | request.callbacks.push({ resolve: resolve, reject: reject });
56 | });
57 | };
58 | ModelBatchRequest.prototype.batchFetch = function () {
59 | this.apiRequests.forEach(function (apiRequest, api) {
60 | apiRequest.forEach(function (_a) {
61 | var query = _a.query, requests = _a.requests;
62 | var ids = Array.from(requests.keys());
63 | ArSyncApi_1.default.syncFetch({ api: api, query: query, params: { ids: ids } }).then(function (models) {
64 | for (var _i = 0, models_1 = models; _i < models_1.length; _i++) {
65 | var model = models_1[_i];
66 | var req = requests.get(model._sync.id);
67 | if (req)
68 | req.model = model;
69 | }
70 | requests.forEach(function (_a) {
71 | var model = _a.model, callbacks = _a.callbacks;
72 | callbacks.forEach(function (cb) { return cb.resolve(model); });
73 | });
74 | }).catch(function (e) {
75 | requests.forEach(function (_a) {
76 | var callbacks = _a.callbacks;
77 | callbacks.forEach(function (cb) { return cb.reject(e); });
78 | });
79 | });
80 | });
81 | });
82 | this.apiRequests.clear();
83 | };
84 | ModelBatchRequest.prototype.setTimer = function () {
85 | var _this = this;
86 | if (this.timer)
87 | return;
88 | this.timer = setTimeout(function () {
89 | _this.timer = null;
90 | _this.batchFetch();
91 | }, 20);
92 | };
93 | return ModelBatchRequest;
94 | }());
95 | var modelBatchRequest = new ModelBatchRequest;
96 | var ArSyncContainerBase = /** @class */ (function () {
97 | function ArSyncContainerBase() {
98 | this.listeners = [];
99 | this.parentModel = null;
100 | }
101 | ArSyncContainerBase.prototype.replaceData = function (_data, _parentSyncKeys) { };
102 | ArSyncContainerBase.prototype.initForReload = function (request) {
103 | var _this = this;
104 | this.networkSubscriber = ArSyncStore.connectionManager.subscribeNetwork(function (state) {
105 | if (!state) {
106 | if (_this.onConnectionChange)
107 | _this.onConnectionChange(false);
108 | return;
109 | }
110 | if (request.id != null) {
111 | modelBatchRequest.fetch(request.api, request.query, request.id).then(function (data) {
112 | if (_this.data && data) {
113 | _this.replaceData(data);
114 | if (_this.onConnectionChange)
115 | _this.onConnectionChange(true);
116 | if (_this.onChange)
117 | _this.onChange([], _this.data);
118 | }
119 | });
120 | }
121 | else {
122 | ArSyncApi_1.default.syncFetch(request).then(function (data) {
123 | if (_this.data && data) {
124 | _this.replaceData(data);
125 | if (_this.onConnectionChange)
126 | _this.onConnectionChange(true);
127 | if (_this.onChange)
128 | _this.onChange([], _this.data);
129 | }
130 | }).catch(function (e) {
131 | console.error("failed to reload. " + e);
132 | });
133 | }
134 | });
135 | };
136 | ArSyncContainerBase.prototype.release = function () {
137 | if (this.networkSubscriber)
138 | this.networkSubscriber.unsubscribe();
139 | this.unsubscribeAll();
140 | for (var _i = 0, _a = Object.values(this.children); _i < _a.length; _i++) {
141 | var child = _a[_i];
142 | if (child)
143 | child.release();
144 | }
145 | this.data = null;
146 | };
147 | ArSyncContainerBase.prototype.onChange = function (path, data) {
148 | if (this.parentModel)
149 | this.parentModel.onChange(__spreadArrays([this.parentKey], path), data);
150 | };
151 | ArSyncContainerBase.prototype.subscribe = function (key, listener) {
152 | this.listeners.push(ArSyncStore.connectionManager.subscribe(key, listener));
153 | };
154 | ArSyncContainerBase.prototype.unsubscribeAll = function () {
155 | for (var _i = 0, _a = this.listeners; _i < _a.length; _i++) {
156 | var l = _a[_i];
157 | l.unsubscribe();
158 | }
159 | this.listeners = [];
160 | };
161 | ArSyncContainerBase.compactQueryAttributes = function (query) {
162 | function compactAttributes(attributes) {
163 | var attrs = {};
164 | var keys = [];
165 | for (var key in attributes) {
166 | var c = compactQuery(attributes[key]);
167 | if (c === true) {
168 | keys.push(key);
169 | }
170 | else {
171 | attrs[key] = c;
172 | }
173 | }
174 | if (Object.keys(attrs).length === 0) {
175 | if (keys.length === 0)
176 | return [true, false];
177 | if (keys.length === 1)
178 | return [keys[0], false];
179 | return [keys, false];
180 | }
181 | var needsEscape = attrs['attributes'] || attrs['params'] || attrs['as'];
182 | if (keys.length === 0)
183 | return [attrs, needsEscape];
184 | return [__spreadArrays(keys, [attrs]), needsEscape];
185 | }
186 | function compactQuery(query) {
187 | if (!('attributes' in query))
188 | return true;
189 | var as = query.as, params = query.params;
190 | var _a = compactAttributes(query.attributes), attributes = _a[0], needsEscape = _a[1];
191 | if (as == null && params == null) {
192 | if (needsEscape)
193 | return { attributes: attributes };
194 | return attributes;
195 | }
196 | var result = {};
197 | if (as)
198 | result.as = as;
199 | if (params)
200 | result.params = params;
201 | if (attributes !== true)
202 | result.attributes = attributes;
203 | return result;
204 | }
205 | var result = compactQuery(query);
206 | if (typeof result === 'object' && 'attributes' in result)
207 | return result.attributes;
208 | return result === true ? {} : result;
209 | };
210 | ArSyncContainerBase.parseQuery = function (query, attrsonly) {
211 | var attributes = {};
212 | var column = null;
213 | var params = null;
214 | if (!query)
215 | query = [];
216 | if (query.constructor !== Array)
217 | query = [query];
218 | for (var _i = 0, query_1 = query; _i < query_1.length; _i++) {
219 | var arg = query_1[_i];
220 | if (typeof (arg) === 'string') {
221 | attributes[arg] = {};
222 | }
223 | else if (typeof (arg) === 'object') {
224 | for (var key in arg) {
225 | var value = arg[key];
226 | if (attrsonly) {
227 | attributes[key] = this.parseQuery(value);
228 | continue;
229 | }
230 | if (key === 'attributes') {
231 | var child = this.parseQuery(value, true);
232 | for (var k in child)
233 | attributes[k] = child[k];
234 | }
235 | else if (key === 'as') {
236 | column = value;
237 | }
238 | else if (key === 'params') {
239 | params = value;
240 | }
241 | else {
242 | attributes[key] = this.parseQuery(value);
243 | }
244 | }
245 | }
246 | }
247 | if (attrsonly)
248 | return attributes;
249 | return { attributes: attributes, as: column, params: params };
250 | };
251 | ArSyncContainerBase.load = function (_a, root) {
252 | var api = _a.api, id = _a.id, params = _a.params, query = _a.query;
253 | var parsedQuery = ArSyncRecord.parseQuery(query);
254 | var compactQueryAttributes = ArSyncRecord.compactQueryAttributes(parsedQuery);
255 | if (id != null) {
256 | return modelBatchRequest.fetch(api, compactQueryAttributes, id).then(function (data) {
257 | if (!data)
258 | throw { retry: false };
259 | var request = { api: api, id: id, query: compactQueryAttributes };
260 | return new ArSyncRecord(parsedQuery, data, request, root, null, null);
261 | });
262 | }
263 | else {
264 | var request_1 = { api: api, query: compactQueryAttributes, params: params };
265 | return ArSyncApi_1.default.syncFetch(request_1).then(function (response) {
266 | if (!response) {
267 | throw { retry: false };
268 | }
269 | else if (response.collection && response.order && response._sync) {
270 | return new ArSyncCollection(response._sync.keys, 'collection', parsedQuery, response, request_1, root, null, null);
271 | }
272 | else if (response instanceof Array) {
273 | return new ArSyncCollection([], '', parsedQuery, response, request_1, root, null, null);
274 | }
275 | else {
276 | return new ArSyncRecord(parsedQuery, response, request_1, root, null, null);
277 | }
278 | });
279 | }
280 | };
281 | return ArSyncContainerBase;
282 | }());
283 | var ArSyncRecord = /** @class */ (function (_super) {
284 | __extends(ArSyncRecord, _super);
285 | function ArSyncRecord(query, data, request, root, parentModel, parentKey) {
286 | var _this = _super.call(this) || this;
287 | _this.fetching = new Set();
288 | _this.root = root;
289 | if (request)
290 | _this.initForReload(request);
291 | _this.query = query;
292 | _this.queryAttributes = query.attributes || {};
293 | _this.data = {};
294 | _this.children = {};
295 | _this.rootRecord = !parentModel;
296 | _this.id = data._sync.id;
297 | _this.syncKeys = data._sync.keys;
298 | _this.replaceData(data);
299 | _this.parentModel = parentModel;
300 | _this.parentKey = parentKey;
301 | return _this;
302 | }
303 | ArSyncRecord.prototype.replaceData = function (data) {
304 | this.id = data._sync.id;
305 | this.syncKeys = data._sync.keys;
306 | this.unsubscribeAll();
307 | this.paths = [];
308 | for (var key in this.queryAttributes) {
309 | var subQuery = this.queryAttributes[key];
310 | var aliasName = subQuery.as || key;
311 | var subData = data[aliasName];
312 | var child = this.children[aliasName];
313 | if (key === '_sync')
314 | continue;
315 | if (subData instanceof Array || (subData && subData.collection && subData.order && subData._sync)) {
316 | if (child) {
317 | child.replaceData(subData, this.syncKeys);
318 | }
319 | else {
320 | var collection = new ArSyncCollection(this.syncKeys, key, subQuery, subData, null, this.root, this, aliasName);
321 | this.mark();
322 | this.children[aliasName] = collection;
323 | this.data[aliasName] = collection.data;
324 | }
325 | }
326 | else {
327 | if (subQuery.attributes && Object.keys(subQuery.attributes).length > 0)
328 | this.paths.push(key);
329 | if (subData && subData._sync) {
330 | if (child) {
331 | child.replaceData(subData);
332 | }
333 | else {
334 | var model = new ArSyncRecord(subQuery, subData, null, this.root, this, aliasName);
335 | this.mark();
336 | this.children[aliasName] = model;
337 | this.data[aliasName] = model.data;
338 | }
339 | }
340 | else {
341 | if (child) {
342 | child.release();
343 | delete this.children[aliasName];
344 | }
345 | if (this.data[aliasName] !== subData) {
346 | this.mark();
347 | this.data[aliasName] = subData;
348 | }
349 | }
350 | }
351 | }
352 | if (this.queryAttributes['*']) {
353 | for (var key in data) {
354 | if (key === '_sync')
355 | continue;
356 | if (!this.queryAttributes[key] && this.data[key] !== data[key]) {
357 | this.mark();
358 | this.data[key] = data[key];
359 | }
360 | }
361 | }
362 | this.subscribeAll();
363 | };
364 | ArSyncRecord.prototype.onNotify = function (notifyData, path) {
365 | var _this = this;
366 | var action = notifyData.action, className = notifyData.class, id = notifyData.id;
367 | var query = path && this.queryAttributes[path];
368 | var aliasName = (query && query.as) || path;
369 | if (action === 'remove') {
370 | var child = this.children[aliasName];
371 | this.fetching.delete(aliasName + ":" + id); // To cancel consumeAdd
372 | if (child)
373 | child.release();
374 | this.children[aliasName] = null;
375 | this.mark();
376 | this.data[aliasName] = null;
377 | this.onChange([aliasName], null);
378 | }
379 | else if (action === 'add') {
380 | var child = this.children[aliasName];
381 | if (child instanceof ArSyncRecord && child.id === id)
382 | return;
383 | var fetchKey_1 = aliasName + ":" + id;
384 | this.fetching.add(fetchKey_1);
385 | modelBatchRequest.fetch(className, ArSyncRecord.compactQueryAttributes(query), id).then(function (data) {
386 | // Record already removed
387 | if (!_this.fetching.has(fetchKey_1))
388 | return;
389 | _this.fetching.delete(fetchKey_1);
390 | if (!data || !_this.data)
391 | return;
392 | var model = new ArSyncRecord(query, data, null, _this.root, _this, aliasName);
393 | var child = _this.children[aliasName];
394 | if (child)
395 | child.release();
396 | _this.children[aliasName] = model;
397 | _this.mark();
398 | _this.data[aliasName] = model.data;
399 | _this.onChange([aliasName], model.data);
400 | }).catch(function (e) {
401 | console.error("failed to load " + className + ":" + id + " " + e);
402 | });
403 | }
404 | else {
405 | var field = notifyData.field;
406 | var query_2 = field ? this.patchQuery(field) : this.reloadQuery();
407 | if (!query_2)
408 | return;
409 | modelBatchRequest.fetch(className, query_2, id).then(function (data) {
410 | if (_this.data)
411 | _this.update(data);
412 | }).catch(function (e) {
413 | console.error("failed to load patch " + className + ":" + id + " " + e);
414 | });
415 | }
416 | };
417 | ArSyncRecord.prototype.subscribeAll = function () {
418 | var _this = this;
419 | var callback = function (data) { return _this.onNotify(data); };
420 | for (var _i = 0, _a = this.syncKeys; _i < _a.length; _i++) {
421 | var key = _a[_i];
422 | this.subscribe(key, callback);
423 | }
424 | var _loop_1 = function (path) {
425 | var pathCallback = function (data) { return _this.onNotify(data, path); };
426 | for (var _i = 0, _a = this_1.syncKeys; _i < _a.length; _i++) {
427 | var key = _a[_i];
428 | this_1.subscribe(key + path, pathCallback);
429 | }
430 | };
431 | var this_1 = this;
432 | for (var _b = 0, _c = this.paths; _b < _c.length; _b++) {
433 | var path = _c[_b];
434 | _loop_1(path);
435 | }
436 | if (this.rootRecord) {
437 | var key = this.syncKeys[0];
438 | if (key)
439 | this.subscribe(key + '_destroy', function () { return _this.root.handleDestroy(); });
440 | }
441 | };
442 | ArSyncRecord.prototype.patchQuery = function (key) {
443 | var _a;
444 | var subQuery = this.queryAttributes[key];
445 | if (subQuery)
446 | return _a = {}, _a[key] = subQuery, _a;
447 | };
448 | ArSyncRecord.prototype.reloadQuery = function () {
449 | if (this.reloadQueryCache)
450 | return this.reloadQueryCache;
451 | var arrayQuery = [];
452 | var hashQuery = {};
453 | for (var key in this.queryAttributes) {
454 | if (key === '_sync')
455 | continue;
456 | var val = this.queryAttributes[key];
457 | if (!val || !val.attributes) {
458 | arrayQuery === null || arrayQuery === void 0 ? void 0 : arrayQuery.push(key);
459 | hashQuery[key] = true;
460 | }
461 | else if (!val.params && Object.keys(val.attributes).length === 0) {
462 | arrayQuery = null;
463 | hashQuery[key] = val;
464 | }
465 | }
466 | return this.reloadQueryCache = arrayQuery || hashQuery;
467 | };
468 | ArSyncRecord.prototype.update = function (data) {
469 | for (var key in data) {
470 | if (key === '_sync')
471 | continue;
472 | var subQuery = this.queryAttributes[key];
473 | if (subQuery && subQuery.attributes && Object.keys(subQuery.attributes).length > 0)
474 | continue;
475 | if (this.data[key] === data[key])
476 | continue;
477 | this.mark();
478 | this.data[key] = data[key];
479 | this.onChange([key], data[key]);
480 | }
481 | };
482 | ArSyncRecord.prototype.markAndSet = function (key, data) {
483 | this.mark();
484 | this.data[key] = data;
485 | };
486 | ArSyncRecord.prototype.mark = function () {
487 | if (!this.root || !this.root.immutable || !Object.isFrozen(this.data))
488 | return;
489 | this.data = __assign({}, this.data);
490 | this.root.mark(this.data);
491 | if (this.parentModel && this.parentKey)
492 | this.parentModel.markAndSet(this.parentKey, this.data);
493 | };
494 | return ArSyncRecord;
495 | }(ArSyncContainerBase));
496 | var ArSyncCollection = /** @class */ (function (_super) {
497 | __extends(ArSyncCollection, _super);
498 | function ArSyncCollection(parentSyncKeys, path, query, data, request, root, parentModel, parentKey) {
499 | var _this = _super.call(this) || this;
500 | _this.ordering = { orderBy: 'id', direction: 'asc' };
501 | _this.aliasOrderKey = 'id';
502 | _this.fetching = new Set();
503 | _this.root = root;
504 | _this.path = path;
505 | _this.query = query;
506 | _this.queryAttributes = query.attributes || {};
507 | _this.compactQueryAttributes = ArSyncRecord.compactQueryAttributes(query);
508 | if (request)
509 | _this.initForReload(request);
510 | if (query.params) {
511 | _this.setOrdering(query.params);
512 | }
513 | _this.data = [];
514 | _this.children = [];
515 | _this.replaceData(data, parentSyncKeys);
516 | _this.parentModel = parentModel;
517 | _this.parentKey = parentKey;
518 | return _this;
519 | }
520 | ArSyncCollection.prototype.setOrdering = function (ordering) {
521 | var direction = 'asc';
522 | var orderBy = 'id';
523 | var first = undefined;
524 | var last = undefined;
525 | if (ordering.direction === 'desc')
526 | direction = ordering.direction;
527 | if (typeof ordering.orderBy === 'string')
528 | orderBy = ordering.orderBy;
529 | if (typeof ordering.first === 'number')
530 | first = ordering.first;
531 | if (typeof ordering.last === 'number')
532 | last = ordering.last;
533 | var subQuery = this.queryAttributes[orderBy];
534 | this.aliasOrderKey = (subQuery && subQuery.as) || orderBy;
535 | this.ordering = { first: first, last: last, direction: direction, orderBy: orderBy };
536 | };
537 | ArSyncCollection.prototype.setSyncKeys = function (parentSyncKeys) {
538 | var _this = this;
539 | if (parentSyncKeys) {
540 | this.syncKeys = parentSyncKeys.map(function (key) { return key + _this.path; });
541 | }
542 | else {
543 | this.syncKeys = [];
544 | }
545 | };
546 | ArSyncCollection.prototype.replaceData = function (data, parentSyncKeys) {
547 | this.setSyncKeys(parentSyncKeys);
548 | var existings = new Map();
549 | for (var _i = 0, _a = this.children; _i < _a.length; _i++) {
550 | var child = _a[_i];
551 | existings.set(child.id, child);
552 | }
553 | var collection;
554 | if (Array.isArray(data)) {
555 | collection = data;
556 | }
557 | else {
558 | collection = data.collection;
559 | this.setOrdering(data.ordering);
560 | }
561 | var newChildren = [];
562 | var newData = [];
563 | for (var _b = 0, collection_1 = collection; _b < collection_1.length; _b++) {
564 | var subData = collection_1[_b];
565 | var model = undefined;
566 | if (typeof (subData) === 'object' && subData && '_sync' in subData)
567 | model = existings.get(subData._sync.id);
568 | var data_1 = subData;
569 | if (model) {
570 | model.replaceData(subData);
571 | }
572 | else if (subData._sync) {
573 | model = new ArSyncRecord(this.query, subData, null, this.root, this, subData._sync.id);
574 | }
575 | if (model) {
576 | newChildren.push(model);
577 | data_1 = model.data;
578 | }
579 | newData.push(data_1);
580 | }
581 | while (this.children.length) {
582 | var child = this.children.pop();
583 | if (!existings.has(child.id))
584 | child.release();
585 | }
586 | if (this.data.length || newChildren.length)
587 | this.mark();
588 | while (this.data.length)
589 | this.data.pop();
590 | for (var _c = 0, newChildren_1 = newChildren; _c < newChildren_1.length; _c++) {
591 | var child = newChildren_1[_c];
592 | this.children.push(child);
593 | }
594 | for (var _d = 0, newData_1 = newData; _d < newData_1.length; _d++) {
595 | var el = newData_1[_d];
596 | this.data.push(el);
597 | }
598 | this.subscribeAll();
599 | };
600 | ArSyncCollection.prototype.consumeAdd = function (className, id) {
601 | var _this = this;
602 | var _a = this.ordering, first = _a.first, last = _a.last, direction = _a.direction;
603 | var limit = first || last;
604 | if (this.children.find(function (a) { return a.id === id; }))
605 | return;
606 | if (limit && limit <= this.children.length) {
607 | var lastItem = this.children[this.children.length - 1];
608 | var firstItem = this.children[0];
609 | if (direction === 'asc') {
610 | if (first) {
611 | if (lastItem && lastItem.id < id)
612 | return;
613 | }
614 | else {
615 | if (firstItem && id < firstItem.id)
616 | return;
617 | }
618 | }
619 | else {
620 | if (first) {
621 | if (lastItem && id < lastItem.id)
622 | return;
623 | }
624 | else {
625 | if (firstItem && firstItem.id < id)
626 | return;
627 | }
628 | }
629 | }
630 | this.fetching.add(id);
631 | modelBatchRequest.fetch(className, this.compactQueryAttributes, id).then(function (data) {
632 | // Record already removed
633 | if (!_this.fetching.has(id))
634 | return;
635 | _this.fetching.delete(id);
636 | if (!data || !_this.data)
637 | return;
638 | var model = new ArSyncRecord(_this.query, data, null, _this.root, _this, id);
639 | var overflow = limit && limit <= _this.data.length;
640 | var rmodel;
641 | _this.mark();
642 | var orderKey = _this.aliasOrderKey;
643 | var firstItem = _this.data[0];
644 | var lastItem = _this.data[_this.data.length - 1];
645 | if (direction === 'asc') {
646 | if (firstItem && data[orderKey] < firstItem[orderKey]) {
647 | _this.children.unshift(model);
648 | _this.data.unshift(model.data);
649 | }
650 | else {
651 | var skipSort = lastItem && lastItem[orderKey] < data[orderKey];
652 | _this.children.push(model);
653 | _this.data.push(model.data);
654 | if (!skipSort)
655 | _this.markAndSort();
656 | }
657 | }
658 | else {
659 | if (firstItem && data[orderKey] > firstItem[orderKey]) {
660 | _this.children.unshift(model);
661 | _this.data.unshift(model.data);
662 | }
663 | else {
664 | var skipSort = lastItem && lastItem[orderKey] > data[orderKey];
665 | _this.children.push(model);
666 | _this.data.push(model.data);
667 | if (!skipSort)
668 | _this.markAndSort();
669 | }
670 | }
671 | if (overflow) {
672 | if (first) {
673 | rmodel = _this.children.pop();
674 | _this.data.pop();
675 | }
676 | else {
677 | rmodel = _this.children.shift();
678 | _this.data.shift();
679 | }
680 | rmodel.release();
681 | }
682 | _this.onChange([model.id], model.data);
683 | if (rmodel)
684 | _this.onChange([rmodel.id], null);
685 | }).catch(function (e) {
686 | console.error("failed to load " + className + ":" + id + " " + e);
687 | });
688 | };
689 | ArSyncCollection.prototype.markAndSort = function () {
690 | this.mark();
691 | var orderKey = this.aliasOrderKey;
692 | if (this.ordering.direction === 'asc') {
693 | this.children.sort(function (a, b) { return a.data[orderKey] < b.data[orderKey] ? -1 : +1; });
694 | this.data.sort(function (a, b) { return a[orderKey] < b[orderKey] ? -1 : +1; });
695 | }
696 | else {
697 | this.children.sort(function (a, b) { return a.data[orderKey] > b.data[orderKey] ? -1 : +1; });
698 | this.data.sort(function (a, b) { return a[orderKey] > b[orderKey] ? -1 : +1; });
699 | }
700 | };
701 | ArSyncCollection.prototype.consumeRemove = function (id) {
702 | var idx = this.children.findIndex(function (a) { return a.id === id; });
703 | this.fetching.delete(id); // To cancel consumeAdd
704 | if (idx < 0)
705 | return;
706 | this.mark();
707 | this.children[idx].release();
708 | this.children.splice(idx, 1);
709 | this.data.splice(idx, 1);
710 | this.onChange([id], null);
711 | };
712 | ArSyncCollection.prototype.onNotify = function (notifyData) {
713 | if (notifyData.action === 'add') {
714 | this.consumeAdd(notifyData.class, notifyData.id);
715 | }
716 | else if (notifyData.action === 'remove') {
717 | this.consumeRemove(notifyData.id);
718 | }
719 | };
720 | ArSyncCollection.prototype.subscribeAll = function () {
721 | var _this = this;
722 | var callback = function (data) { return _this.onNotify(data); };
723 | for (var _i = 0, _a = this.syncKeys; _i < _a.length; _i++) {
724 | var key = _a[_i];
725 | this.subscribe(key, callback);
726 | }
727 | };
728 | ArSyncCollection.prototype.onChange = function (path, data) {
729 | _super.prototype.onChange.call(this, path, data);
730 | if (path[1] === this.aliasOrderKey)
731 | this.markAndSort();
732 | };
733 | ArSyncCollection.prototype.markAndSet = function (id, data) {
734 | this.mark();
735 | var idx = this.children.findIndex(function (a) { return a.id === id; });
736 | if (idx >= 0)
737 | this.data[idx] = data;
738 | };
739 | ArSyncCollection.prototype.mark = function () {
740 | if (!this.root || !this.root.immutable || !Object.isFrozen(this.data))
741 | return;
742 | this.data = __spreadArrays(this.data);
743 | this.root.mark(this.data);
744 | if (this.parentModel && this.parentKey)
745 | this.parentModel.markAndSet(this.parentKey, this.data);
746 | };
747 | return ArSyncCollection;
748 | }(ArSyncContainerBase));
749 | var ArSyncStore = /** @class */ (function () {
750 | function ArSyncStore(request, _a) {
751 | var immutable = (_a === void 0 ? {} : _a).immutable;
752 | this.complete = false;
753 | this.destroyed = false;
754 | this.immutable = !!immutable;
755 | this.markedForFreezeObjects = [];
756 | this.changes = [];
757 | this.eventListeners = { events: {}, serial: 0 };
758 | this.request = request;
759 | this.data = null;
760 | this.load(0);
761 | }
762 | ArSyncStore.prototype.handleDestroy = function () {
763 | this.release();
764 | this.data = null;
765 | this.destroyed = true;
766 | this.trigger('destroy');
767 | };
768 | ArSyncStore.prototype.load = function (retryCount) {
769 | var _this = this;
770 | ArSyncContainerBase.load(this.request, this).then(function (container) {
771 | if (_this.markForRelease) {
772 | container.release();
773 | return;
774 | }
775 | _this.container = container;
776 | _this.data = container.data;
777 | if (_this.immutable)
778 | _this.freezeRecursive(_this.data);
779 | _this.complete = true;
780 | _this.notfound = false;
781 | _this.trigger('load');
782 | _this.trigger('change', { path: [], value: _this.data });
783 | container.onChange = function (path, value) {
784 | _this.changes.push({ path: path, value: value });
785 | _this.setChangesBufferTimer();
786 | };
787 | container.onConnectionChange = function (state) {
788 | _this.trigger('connection', state);
789 | };
790 | }).catch(function (e) {
791 | if (!e || e.retry === undefined)
792 | throw e;
793 | if (_this.markForRelease)
794 | return;
795 | if (!e.retry) {
796 | _this.complete = true;
797 | _this.notfound = true;
798 | _this.trigger('load');
799 | return;
800 | }
801 | var sleepSeconds = Math.min(Math.pow(2, retryCount), 30);
802 | _this.retryLoadTimer = setTimeout(function () {
803 | _this.retryLoadTimer = null;
804 | _this.load(retryCount + 1);
805 | }, sleepSeconds * 1000);
806 | });
807 | };
808 | ArSyncStore.prototype.setChangesBufferTimer = function () {
809 | var _this = this;
810 | if (this.changesBufferTimer)
811 | return;
812 | this.changesBufferTimer = setTimeout(function () {
813 | _this.changesBufferTimer = null;
814 | var changes = _this.changes;
815 | _this.changes = [];
816 | _this.freezeMarked();
817 | _this.data = _this.container.data;
818 | changes.forEach(function (patch) { return _this.trigger('change', patch); });
819 | }, 20);
820 | };
821 | ArSyncStore.prototype.subscribe = function (event, callback) {
822 | var listeners = this.eventListeners.events[event];
823 | if (!listeners)
824 | this.eventListeners.events[event] = listeners = {};
825 | var id = this.eventListeners.serial++;
826 | listeners[id] = callback;
827 | return { unsubscribe: function () { delete listeners[id]; } };
828 | };
829 | ArSyncStore.prototype.trigger = function (event, arg) {
830 | var listeners = this.eventListeners.events[event];
831 | if (!listeners)
832 | return;
833 | for (var id in listeners)
834 | listeners[id](arg);
835 | };
836 | ArSyncStore.prototype.mark = function (object) {
837 | this.markedForFreezeObjects.push(object);
838 | };
839 | ArSyncStore.prototype.freezeRecursive = function (obj) {
840 | if (Object.isFrozen(obj))
841 | return obj;
842 | for (var key in obj)
843 | this.freezeRecursive(obj[key]);
844 | Object.freeze(obj);
845 | };
846 | ArSyncStore.prototype.freezeMarked = function () {
847 | var _this = this;
848 | this.markedForFreezeObjects.forEach(function (obj) { return _this.freezeRecursive(obj); });
849 | this.markedForFreezeObjects = [];
850 | };
851 | ArSyncStore.prototype.release = function () {
852 | if (this.retryLoadTimer)
853 | clearTimeout(this.retryLoadTimer);
854 | if (this.changesBufferTimer)
855 | clearTimeout(this.changesBufferTimer);
856 | if (this.container) {
857 | this.container.release();
858 | }
859 | else {
860 | this.markForRelease = true;
861 | }
862 | };
863 | return ArSyncStore;
864 | }());
865 | exports.ArSyncStore = ArSyncStore;
866 |
--------------------------------------------------------------------------------
/core/ConnectionAdapter.d.ts:
--------------------------------------------------------------------------------
1 | export default interface ConnectionAdapter {
2 | ondisconnect: (() => void) | null;
3 | onreconnect: (() => void) | null;
4 | subscribe(key: string, callback: (data: any) => void): {
5 | unsubscribe: () => void;
6 | };
7 | }
8 |
--------------------------------------------------------------------------------
/core/ConnectionAdapter.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 |
--------------------------------------------------------------------------------
/core/ConnectionManager.d.ts:
--------------------------------------------------------------------------------
1 | export default class ConnectionManager {
2 | subscriptions: any;
3 | adapter: any;
4 | networkListeners: any;
5 | networkListenerSerial: any;
6 | networkStatus: any;
7 | constructor(adapter: any);
8 | triggerNetworkChange(status: any): void;
9 | unsubscribeAll(): void;
10 | subscribeNetwork(func: any): {
11 | unsubscribe: () => void;
12 | };
13 | subscribe(key: any, func: any): {
14 | unsubscribe(): void;
15 | };
16 | connect(key: any): any;
17 | disconnect(key: any): void;
18 | received(key: any, data: any): void;
19 | }
20 |
--------------------------------------------------------------------------------
/core/ConnectionManager.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 | var ConnectionManager = /** @class */ (function () {
4 | function ConnectionManager(adapter) {
5 | var _this = this;
6 | this.subscriptions = {};
7 | this.adapter = adapter;
8 | this.networkListeners = {};
9 | this.networkListenerSerial = 0;
10 | this.networkStatus = true;
11 | adapter.ondisconnect = function () {
12 | _this.unsubscribeAll();
13 | _this.triggerNetworkChange(false);
14 | };
15 | adapter.onreconnect = function () { return _this.triggerNetworkChange(true); };
16 | }
17 | ConnectionManager.prototype.triggerNetworkChange = function (status) {
18 | if (this.networkStatus == status)
19 | return;
20 | this.networkStatus = status;
21 | for (var id in this.networkListeners)
22 | this.networkListeners[id](status);
23 | };
24 | ConnectionManager.prototype.unsubscribeAll = function () {
25 | for (var id in this.subscriptions) {
26 | var subscription = this.subscriptions[id];
27 | subscription.listeners = {};
28 | subscription.connection.unsubscribe();
29 | }
30 | this.subscriptions = {};
31 | };
32 | ConnectionManager.prototype.subscribeNetwork = function (func) {
33 | var _this = this;
34 | var id = this.networkListenerSerial++;
35 | this.networkListeners[id] = func;
36 | var unsubscribe = function () {
37 | delete _this.networkListeners[id];
38 | };
39 | return { unsubscribe: unsubscribe };
40 | };
41 | ConnectionManager.prototype.subscribe = function (key, func) {
42 | var _this = this;
43 | if (!this.networkStatus)
44 | return { unsubscribe: function () { } };
45 | var subscription = this.connect(key);
46 | var id = subscription.serial++;
47 | subscription.ref++;
48 | subscription.listeners[id] = func;
49 | var unsubscribe = function () {
50 | if (!subscription.listeners[id])
51 | return;
52 | delete subscription.listeners[id];
53 | subscription.ref--;
54 | if (subscription.ref === 0)
55 | _this.disconnect(key);
56 | };
57 | return { unsubscribe: unsubscribe };
58 | };
59 | ConnectionManager.prototype.connect = function (key) {
60 | var _this = this;
61 | if (this.subscriptions[key])
62 | return this.subscriptions[key];
63 | var connection = this.adapter.subscribe(key, function (data) { return _this.received(key, data); });
64 | return this.subscriptions[key] = { connection: connection, listeners: {}, ref: 0, serial: 0 };
65 | };
66 | ConnectionManager.prototype.disconnect = function (key) {
67 | var subscription = this.subscriptions[key];
68 | if (!subscription || subscription.ref !== 0)
69 | return;
70 | delete this.subscriptions[key];
71 | subscription.connection.unsubscribe();
72 | };
73 | ConnectionManager.prototype.received = function (key, data) {
74 | var subscription = this.subscriptions[key];
75 | if (!subscription)
76 | return;
77 | for (var id in subscription.listeners)
78 | subscription.listeners[id](data);
79 | };
80 | return ConnectionManager;
81 | }());
82 | exports.default = ConnectionManager;
83 |
--------------------------------------------------------------------------------
/core/DataType.d.ts:
--------------------------------------------------------------------------------
1 | declare type RecordType = {
2 | _meta?: {
3 | query: any;
4 | };
5 | };
6 | declare type Values = T[keyof T];
7 | declare type AddNullable = null extends Test ? Type | null : Type;
8 | declare type DataTypeExtractField = Exclude extends RecordType ? AddNullable : BaseType[Key] extends RecordType[] ? {}[] : BaseType[Key];
9 | declare type DataTypeExtractFieldsFromQuery = '*' extends Fields ? {
10 | [key in Exclude]: DataTypeExtractField;
11 | } : {
12 | [key in Fields & keyof (BaseType)]: DataTypeExtractField;
13 | };
14 | interface ExtraFieldErrorType {
15 | error: 'extraFieldError';
16 | }
17 | declare type DataTypeExtractFromQueryHash = '*' extends keyof QueryType ? {
18 | [key in Exclude<(keyof BaseType) | (keyof QueryType), '_meta' | '_params' | '*'>]: (key extends keyof BaseType ? (key extends keyof QueryType ? (QueryType[key] extends true ? DataTypeExtractField : AddNullable>) : DataTypeExtractField) : ExtraFieldErrorType);
19 | } : {
20 | [key in keyof QueryType]: (key extends keyof BaseType ? (QueryType[key] extends true ? DataTypeExtractField : AddNullable>) : ExtraFieldErrorType);
21 | };
22 | declare type _DataTypeFromQuery = QueryType extends keyof BaseType | '*' ? DataTypeExtractFieldsFromQuery : QueryType extends Readonly<(keyof BaseType | '*')[]> ? DataTypeExtractFieldsFromQuery> : QueryType extends {
23 | as: string;
24 | } ? {
25 | error: 'type for alias field is not supported';
26 | } | undefined : DataTypeExtractFromQueryHash;
27 | export declare type DataTypeFromQuery = BaseType extends any[] ? CheckAttributesField[] : AddNullable>;
28 | declare type CheckAttributesField = Q extends {
29 | attributes: infer R;
30 | } ? _DataTypeFromQuery
: _DataTypeFromQuery
;
31 | declare type IsAnyCompareLeftType = {
32 | __any: never;
33 | };
34 | declare type CollectExtraFields = IsAnyCompareLeftType extends Type ? never : Type extends ExtraFieldErrorType ? Key : Type extends (infer R)[] ? {
35 | 0: Values<{
36 | [key in keyof R]: CollectExtraFields;
37 | }>;
38 | 1: never;
39 | }[R extends object ? 0 : 1] : {
40 | 0: Values<{
41 | [key in keyof Type]: CollectExtraFields;
42 | }>;
43 | 1: never;
44 | }[Type extends object ? 0 : 1];
45 | declare type SelectString = T extends string ? T : never;
46 | declare type _ValidateDataTypeExtraFileds = SelectString extends never ? Type : {
47 | error: {
48 | extraFields: Extra;
49 | };
50 | };
51 | declare type ValidateDataTypeExtraFileds = _ValidateDataTypeExtraFileds, Type>;
52 | declare type RequestBase = {
53 | api: string;
54 | query: any;
55 | id?: number;
56 | params?: any;
57 | _meta?: {
58 | data: any;
59 | };
60 | };
61 | declare type DataTypeBaseFromRequestType = R extends {
62 | _meta?: {
63 | data: infer DataType;
64 | };
65 | } ? (ID extends number ? ([DataType, R['params']] extends [(infer DT)[], {
66 | ids: number[];
67 | } | undefined] ? DT : never) : DataType) : never;
68 | export declare type DataTypeFromRequest = ValidateDataTypeExtraFileds, R['query']>>;
69 | export {};
70 |
--------------------------------------------------------------------------------
/core/DataType.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 |
--------------------------------------------------------------------------------
/core/hooks.d.ts:
--------------------------------------------------------------------------------
1 | declare let useState: (t: T | (() => T)) => [T, (t: T | ((t: T) => T)) => void];
2 | declare let useEffect: (f: (() => void) | (() => (() => void)), deps: any[]) => void;
3 | declare let useMemo: (f: () => T, deps: any[]) => T;
4 | declare let useRef: (value: T) => {
5 | current: T;
6 | };
7 | declare type InitializeHooksParams = {
8 | useState: typeof useState;
9 | useEffect: typeof useEffect;
10 | useMemo: typeof useMemo;
11 | useRef: typeof useRef;
12 | };
13 | export declare function initializeHooks(hooks: InitializeHooksParams): void;
14 | interface ModelStatus {
15 | complete: boolean;
16 | notfound?: boolean;
17 | connected: boolean;
18 | destroyed: boolean;
19 | }
20 | export declare type DataAndStatus = [T | null, ModelStatus];
21 | export interface Request {
22 | api: string;
23 | params?: any;
24 | id?: number;
25 | query: any;
26 | }
27 | export declare function useArSyncModel(request: Request | null): DataAndStatus;
28 | interface FetchStatus {
29 | complete: boolean;
30 | notfound?: boolean;
31 | }
32 | declare type DataStatusUpdate = [T | null, FetchStatus, () => void];
33 | export declare function useArSyncFetch(request: Request | null): DataStatusUpdate;
34 | export {};
35 |
--------------------------------------------------------------------------------
/core/hooks.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 | exports.useArSyncFetch = exports.useArSyncModel = exports.initializeHooks = void 0;
4 | var ArSyncApi_1 = require("./ArSyncApi");
5 | var ArSyncModel_1 = require("./ArSyncModel");
6 | var useState;
7 | var useEffect;
8 | var useMemo;
9 | var useRef;
10 | function initializeHooks(hooks) {
11 | useState = hooks.useState;
12 | useEffect = hooks.useEffect;
13 | useMemo = hooks.useMemo;
14 | useRef = hooks.useRef;
15 | }
16 | exports.initializeHooks = initializeHooks;
17 | function checkHooks() {
18 | if (!useState)
19 | throw 'uninitialized. needs `initializeHooks({ useState, useEffect, useMemo, useRef })`';
20 | }
21 | var initialResult = [null, { complete: false, notfound: undefined, connected: true, destroyed: false }];
22 | function useArSyncModel(request) {
23 | var _a;
24 | checkHooks();
25 | var _b = useState(initialResult), result = _b[0], setResult = _b[1];
26 | var requestString = JSON.stringify((_a = request === null || request === void 0 ? void 0 : request.id) !== null && _a !== void 0 ? _a : request === null || request === void 0 ? void 0 : request.params);
27 | var prevRequestStringRef = useRef(requestString);
28 | useEffect(function () {
29 | prevRequestStringRef.current = requestString;
30 | if (!request) {
31 | setResult(initialResult);
32 | return function () { };
33 | }
34 | var model = new ArSyncModel_1.default(request, { immutable: true });
35 | function update() {
36 | var complete = model.complete, notfound = model.notfound, connected = model.connected, destroyed = model.destroyed, data = model.data;
37 | setResult(function (resultWas) {
38 | var dataWas = resultWas[0], statusWas = resultWas[1];
39 | var statusPersisted = statusWas.complete === complete && statusWas.notfound === notfound && statusWas.connected === connected && statusWas.destroyed === destroyed;
40 | if (dataWas === data && statusPersisted)
41 | return resultWas;
42 | var status = statusPersisted ? statusWas : { complete: complete, notfound: notfound, connected: connected, destroyed: destroyed };
43 | return [data, status];
44 | });
45 | }
46 | if (model.complete) {
47 | update();
48 | }
49 | else {
50 | setResult(initialResult);
51 | }
52 | model.subscribe('load', update);
53 | model.subscribe('change', update);
54 | model.subscribe('destroy', update);
55 | model.subscribe('connection', update);
56 | return function () { return model.release(); };
57 | }, [requestString]);
58 | return prevRequestStringRef.current === requestString ? result : initialResult;
59 | }
60 | exports.useArSyncModel = useArSyncModel;
61 | var initialFetchState = { data: null, status: { complete: false, notfound: undefined } };
62 | function extractParams(query, output) {
63 | if (output === void 0) { output = []; }
64 | if (typeof (query) !== 'object' || query == null || Array.isArray(query))
65 | return output;
66 | if ('params' in query)
67 | output.push(query.params);
68 | for (var key in query) {
69 | extractParams(query[key], output);
70 | }
71 | return output;
72 | }
73 | function useArSyncFetch(request) {
74 | var _a;
75 | checkHooks();
76 | var _b = useState(initialFetchState), state = _b[0], setState = _b[1];
77 | var query = request && request.query;
78 | var resourceIdentifier = (_a = request === null || request === void 0 ? void 0 : request.id) !== null && _a !== void 0 ? _a : request === null || request === void 0 ? void 0 : request.params;
79 | var requestString = useMemo(function () {
80 | return JSON.stringify(extractParams(query, [resourceIdentifier]));
81 | }, [query, resourceIdentifier]);
82 | var prevRequestStringRef = useRef(requestString);
83 | var loader = useMemo(function () {
84 | var lastLoadId = 0;
85 | var timer = null;
86 | function cancel() {
87 | if (timer)
88 | clearTimeout(timer);
89 | timer = null;
90 | lastLoadId++;
91 | }
92 | function fetch(request, retryCount) {
93 | cancel();
94 | var currentLoadingId = lastLoadId;
95 | ArSyncApi_1.default.fetch(request).then(function (response) {
96 | if (currentLoadingId !== lastLoadId)
97 | return;
98 | setState({ data: response, status: { complete: true, notfound: false } });
99 | }).catch(function (e) {
100 | if (currentLoadingId !== lastLoadId)
101 | return;
102 | if (!e.retry) {
103 | setState({ data: null, status: { complete: true, notfound: true } });
104 | return;
105 | }
106 | timer = setTimeout(function () { return fetch(request, retryCount + 1); }, 1000 * Math.min(Math.pow(4, retryCount), 30));
107 | });
108 | }
109 | function update() {
110 | if (request) {
111 | setState(function (state) {
112 | var data = state.data, status = state.status;
113 | if (!status.complete && status.notfound === undefined)
114 | return state;
115 | return { data: data, status: { complete: false, notfound: undefined } };
116 | });
117 | fetch(request, 0);
118 | }
119 | else {
120 | setState(initialFetchState);
121 | }
122 | }
123 | return { update: update, cancel: cancel };
124 | }, [requestString]);
125 | useEffect(function () {
126 | prevRequestStringRef.current = requestString;
127 | setState(initialFetchState);
128 | loader.update();
129 | return function () { return loader.cancel(); };
130 | }, [requestString]);
131 | var responseState = prevRequestStringRef.current === requestString ? state : initialFetchState;
132 | return [responseState.data, responseState.status, loader.update];
133 | }
134 | exports.useArSyncFetch = useArSyncFetch;
135 |
--------------------------------------------------------------------------------
/gemfiles/Gemfile-rails-6:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4 |
5 | # Specify your gem's dependencies in ar_sync.gemspec
6 | gemspec path: '..'
7 |
8 | gem 'bigdecimal'
9 | gem 'base64'
10 | gem 'mutex_m'
11 | gem 'logger'
12 | gem 'concurrent-ruby', '1.3.4'
13 | gem 'sqlite3', '~> 1.4'
14 | gem 'activerecord', '~> 6.0'
15 | gem 'ar_serializer', github: 'tompng/ar_serializer'
16 |
--------------------------------------------------------------------------------
/gemfiles/Gemfile-rails-7:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4 |
5 | # Specify your gem's dependencies in ar_sync.gemspec
6 | gemspec path: '..'
7 |
8 | gem 'sqlite3', '~> 1.4'
9 | gem 'activerecord', '~> 7.0'
10 | gem 'ar_serializer', github: 'tompng/ar_serializer'
11 |
--------------------------------------------------------------------------------
/gemfiles/Gemfile-rails-8:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4 |
5 | # Specify your gem's dependencies in ar_sync.gemspec
6 | gemspec path: '..'
7 |
8 | gem 'sqlite3'
9 | gem 'activerecord', '~> 8.0'
10 | gem 'ar_serializer', github: 'tompng/ar_serializer'
11 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | export { default as ArSyncModel } from './core/ArSyncModel';
2 | export { default as ArSyncApi } from './core/ArSyncApi';
3 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | Object.defineProperty(exports, "__esModule", { value: true });
3 | var ArSyncModel_1 = require("./core/ArSyncModel");
4 | Object.defineProperty(exports, "ArSyncModel", { enumerable: true, get: function () { return ArSyncModel_1.default; } });
5 | var ArSyncApi_1 = require("./core/ArSyncApi");
6 | Object.defineProperty(exports, "ArSyncApi", { enumerable: true, get: function () { return ArSyncApi_1.default; } });
7 |
--------------------------------------------------------------------------------
/lib/ar_sync.rb:
--------------------------------------------------------------------------------
1 | module ArSync
2 | module ModelBase; end
3 | end
4 | require 'ar_sync/version'
5 | require 'ar_sync/core'
6 | require 'ar_sync/config'
7 | require 'ar_sync/type_script'
8 | require 'ar_sync/rails' if Kernel.const_defined?('Rails')
9 |
--------------------------------------------------------------------------------
/lib/ar_sync/class_methods.rb:
--------------------------------------------------------------------------------
1 | require_relative 'collection'
2 |
3 | module ArSync::ModelBase::ClassMethods
4 | def _sync_self?
5 | return true if defined?(@_sync_self)
6 |
7 | superclass._sync_self? if superclass < ActiveRecord::Base
8 | end
9 |
10 | def _sync_parents_info
11 | @_sync_parents_info ||= []
12 | end
13 |
14 | def _sync_children_info
15 | @_sync_children_info ||= {}
16 | end
17 |
18 | def _sync_child_info(name)
19 | info = _sync_children_info[name]
20 | return info if info
21 | superclass._sync_child_info name if superclass < ActiveRecord::Base
22 | end
23 |
24 | def _each_sync_parent(&block)
25 | _sync_parents_info.each { |parent, options| block.call(parent, **options) }
26 | superclass._each_sync_parent(&block) if superclass < ActiveRecord::Base
27 | end
28 |
29 | def _each_sync_child(&block)
30 | _sync_children_info.each(&block)
31 | superclass._each_sync_child(&block) if superclass < ActiveRecord::Base
32 | end
33 |
34 | def sync_parent(parent, inverse_of:, only_to: nil, watch: nil)
35 | _initialize_sync_callbacks
36 | _sync_parents_info << [
37 | parent,
38 | { inverse_name: inverse_of, only_to: only_to, watch: watch }
39 | ]
40 | end
41 |
42 | def sync_collection(name)
43 | ArSync::Collection.find self, name
44 | end
45 |
46 | def sync_has_data(*names, **option, &data_block)
47 | @_sync_self = true
48 | names.each do |name|
49 | _sync_children_info[name] = nil
50 | _sync_define name, **option, &data_block
51 | end
52 | end
53 |
54 | def sync_has_many(name, **option, &data_block)
55 | _sync_children_info[name] = [:many, option, data_block]
56 | _sync_has_many name, **option, &data_block
57 | end
58 |
59 | def sync_has_one(name, **option, &data_block)
60 | _sync_children_info[name] = [:one, option, data_block]
61 | _sync_define name, **option, &data_block
62 | end
63 |
64 | def _sync_has_many(name, direction: :asc, first: nil, last: nil, preload: nil, association: nil, **option, &data_block)
65 | raise ArgumentError, 'direction not in [:asc, :desc]' unless %i[asc desc].include? direction
66 | raise ArgumentError, 'first and last cannot be both specified' if first && last
67 | raise ArgumentError, 'cannot use first or last with direction: :desc' if direction != :asc && !first && !last
68 | if data_block.nil? && preload.nil?
69 | association_name = association || name.to_s.underscore.to_sym
70 | order_option_from_params = lambda do |params|
71 | if first || last
72 | params_first = first && [first, params[:first]&.to_i].compact.min
73 | params_last = last && [last, params[:last]&.to_i].compact.min
74 | { direction: direction, first: params_first, last: params_last }
75 | else
76 | {
77 | first: params[:first]&.to_i,
78 | last: params[:last]&.to_i,
79 | order_by: params[:order_by],
80 | direction: params[:direction] || :asc
81 | }
82 | end
83 | end
84 | preload = lambda do |records, _context, **params|
85 | ArSerializer::Field.preload_association(
86 | self,
87 | records,
88 | association_name,
89 | **order_option_from_params.call(params)
90 | )
91 | end
92 | data_block = lambda do |preloaded, _context, **params|
93 | records = preloaded ? preloaded[id] || [] : __send__(name)
94 | next records unless first || last
95 | ArSync::CollectionWithOrder.new(
96 | records,
97 | **order_option_from_params.call(params)
98 | )
99 | end
100 | serializer_data_block = lambda do |preloaded, _context, **_params|
101 | preloaded ? preloaded[id] || [] : __send__(name)
102 | end
103 | if first
104 | params_type = { first?: :int }
105 | elsif last
106 | params_type = { last?: :int }
107 | else
108 | params_type = lambda do
109 | orderable_keys = reflect_on_association(association_name)&.klass&._serializer_orderable_field_keys || []
110 | orderable_keys &= [*option[:only]].map(&:to_s) if option[:only]
111 | orderable_keys -= [*option[:except]].map(&:to_s) if option[:except]
112 | orderable_keys |= ['id']
113 | order_by = orderable_keys.size == 1 ? orderable_keys.first : orderable_keys.sort
114 | { first?: :int, last?: :int, direction?: %w[asc desc], orderBy?: order_by }
115 | end
116 | end
117 | else
118 | params_type = {}
119 | end
120 | _sync_define(
121 | name,
122 | serializer_data_block: serializer_data_block,
123 | preload: preload,
124 | association: association,
125 | params_type: params_type,
126 | **option,
127 | &data_block
128 | )
129 | end
130 |
131 | def _sync_define(name, serializer_data_block: nil, **option, &data_block)
132 | _initialize_sync_callbacks
133 | serializer_field name, **option, &(serializer_data_block || data_block) unless _serializer_field_info name
134 | serializer_field name, **option, namespace: :sync, &data_block
135 | end
136 |
137 | def sync_define_collection(name, first: nil, last: nil, direction: :asc)
138 | _initialize_sync_callbacks
139 | collection = ArSync::Collection.new self, name, first: first, last: last, direction: direction
140 | sync_parent collection, inverse_of: [self, name]
141 | end
142 |
143 | module WriteHook
144 | def _initialize_sync_info_before_mutation
145 | return unless defined? @_initialized
146 | if new_record?
147 | @_sync_watch_values_before_mutation ||= {}
148 | @_sync_parents_info_before_mutation ||= {}
149 | @_sync_belongs_to_info_before_mutation ||= {}
150 | else
151 | self.class.default_scoped.scoping do
152 | @_sync_watch_values_before_mutation ||= _sync_current_watch_values
153 | @_sync_parents_info_before_mutation ||= _sync_current_parents_info
154 | @_sync_belongs_to_info_before_mutation ||= _sync_current_belongs_to_info
155 | end
156 | end
157 | end
158 | def _write_attribute(attr_name, value)
159 | _initialize_sync_info_before_mutation
160 | super attr_name, value
161 | end
162 | def write_attribute(attr_name, value)
163 | _initialize_sync_info_before_mutation
164 | super attr_name, value
165 | end
166 | end
167 |
168 | def _initialize_sync_callbacks
169 | return if defined? @_sync_callbacks_initialized
170 | @_sync_callbacks_initialized = true
171 | prepend WriteHook
172 | attr_reader :_sync_parents_info_before_mutation, :_sync_belongs_to_info_before_mutation, :_sync_watch_values_before_mutation
173 |
174 | _sync_define :id
175 |
176 | serializer_defaults namespace: :sync do |current_user|
177 | { _sync: _sync_field(current_user) }
178 | end
179 |
180 | after_initialize do
181 | @_initialized = true
182 | end
183 |
184 | before_destroy do
185 | @_sync_parents_info_before_mutation ||= _sync_current_parents_info
186 | @_sync_watch_values_before_mutation ||= _sync_current_watch_values
187 | @_sync_belongs_to_info_before_mutation ||= _sync_current_belongs_to_info
188 | end
189 |
190 | %i[create update destroy].each do |action|
191 | after_commit on: action do
192 | next if ArSync.skip_notification?
193 | self.class.default_scoped.scoping { _sync_notify action }
194 | @_sync_watch_values_before_mutation = nil
195 | @_sync_parents_info_before_mutation = nil
196 | @_sync_belongs_to_info_before_mutation = nil
197 | end
198 | end
199 | end
200 | end
201 |
--------------------------------------------------------------------------------
/lib/ar_sync/collection.rb:
--------------------------------------------------------------------------------
1 | class ArSync::Collection
2 | attr_reader :klass, :name, :first, :last, :direction, :ordering
3 | def initialize(klass, name, first: nil, last: nil, direction: nil)
4 | direction ||= :asc
5 | @klass = klass
6 | @name = name
7 | @first = first
8 | @last = last
9 | @direction = direction
10 | @ordering = { first: first, last: last, direction: direction }.compact
11 | self.class.defined_collections[[klass, name]] = self
12 | define_singleton_method(name) { to_a }
13 | end
14 |
15 | def to_a
16 | if first
17 | klass.order(id: direction).limit(first).to_a
18 | elsif last
19 | rev = direction == :asc ? :desc : :asc
20 | klass.order(id: rev).limit(last).reverse
21 | else
22 | klass.all.to_a
23 | end
24 | end
25 |
26 | def self.defined_collections
27 | @defined_collections ||= {}
28 | end
29 |
30 | def self.find(klass, name)
31 | defined_collections[[klass, name]]
32 | end
33 |
34 | def _sync_notify_child_changed(_name, _to_user); end
35 |
36 | def _sync_notify_child_added(child, _name, to_user)
37 | ArSync.sync_send to: self, action: :add, model: child, path: :collection, to_user: to_user
38 | end
39 |
40 | def _sync_notify_child_removed(child, _name, to_user, _owned)
41 | ArSync.sync_send to: self, action: :remove, model: child, path: :collection, to_user: to_user
42 | end
43 |
44 | def self._sync_children_info
45 | @sync_children_info ||= {}
46 | end
47 |
48 | def self._sync_child_info(key)
49 | _sync_children_info[key]
50 | end
51 | end
52 |
53 | class ArSync::CollectionWithOrder < ArSerializer::CustomSerializable
54 | def initialize(records, direction:, first: nil, last: nil)
55 | super records do |results|
56 | {
57 | ordering: { direction: direction || :asc, first: first, last: last }.compact,
58 | collection: records.map(&results).compact
59 | }
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/ar_sync/config.rb:
--------------------------------------------------------------------------------
1 | module ArSync
2 | config_keys = %i[
3 | current_user_method
4 | key_secret
5 | key_prefix
6 | key_expires_in
7 | ]
8 | Config = Struct.new(*config_keys)
9 |
10 | def self.config
11 | @config ||= Config.new :current_user, nil, nil
12 | end
13 |
14 | def self.configure
15 | yield config
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/ar_sync/core.rb:
--------------------------------------------------------------------------------
1 | require 'ar_serializer'
2 | require_relative 'collection'
3 | require_relative 'class_methods'
4 | require_relative 'instance_methods'
5 |
6 | module ArSync
7 | ArSync::ModelBase.module_eval do
8 | extend ActiveSupport::Concern
9 | include ArSync::ModelBase::InstanceMethods
10 | end
11 |
12 | def self.on_notification(&block)
13 | @sync_send_block = block
14 | end
15 |
16 | def self.with_compact_notification
17 | key = :ar_sync_compact_notifications
18 | Thread.current[key] = []
19 | yield
20 | ensure
21 | events = Thread.current[key]
22 | Thread.current[key] = nil
23 | @sync_send_block&.call events if events.present?
24 | end
25 |
26 | def self.skip_notification?
27 | Thread.current[:ar_sync_skip_notification]
28 | end
29 |
30 | def self.without_notification
31 | key = :ar_sync_skip_notification
32 | flag_was = Thread.current[key]
33 | Thread.current[key] = true
34 | yield
35 | ensure
36 | Thread.current[key] = flag_was
37 | end
38 |
39 | def self.sync_send(to:, action:, model:, path: nil, field: nil, to_user: nil)
40 | key = sync_key to, to_user, signature: false
41 | e = { action: action }
42 | e[:field] = field if field
43 | if model
44 | e[:class] = model.class.base_class.name
45 | e[:id] = model.id
46 | end
47 | event = ["#{key}#{path}", e]
48 | buffer = Thread.current[:ar_sync_compact_notifications]
49 | if buffer
50 | buffer << event
51 | else
52 | @sync_send_block&.call [event]
53 | end
54 | end
55 |
56 | def self.sync_key(model, to_user = nil, signature: true)
57 | if model.is_a? ArSync::Collection
58 | key = [to_user&.id, model.klass.name, model.name].join '/'
59 | else
60 | key = [to_user&.id, model.class.name, model.id].join '/'
61 | end
62 | key = Digest::SHA256.hexdigest("#{config.key_secret}#{key}")[0, 32] if config.key_secret
63 | key = "#{config.key_prefix}#{key}" if config.key_prefix
64 | key = signature && config.key_expires_in ? signed_key(key, Time.now.to_i.to_s) : key
65 | key + ';/'
66 | end
67 |
68 | def self.validate_expiration(signed_key)
69 | signed_key = signed_key.to_s
70 | return signed_key unless config.key_expires_in
71 | key, time, signature, other = signed_key.split ';', 4
72 | return unless signed_key(key, time) == [key, time, signature].join(';')
73 | [key, other].compact.join ';' if Time.now.to_i - time.to_i < config.key_expires_in
74 | end
75 |
76 | def self.signed_key(key, time)
77 | "#{key};#{time};#{Digest::SHA256.hexdigest("#{config.key_secret}#{key};#{time}")[0, 16]}"
78 | end
79 |
80 | def self.serialize(record_or_records, query, user: nil)
81 | ArSerializer.serialize record_or_records, query, context: user, use: :sync
82 | end
83 |
84 | def self.sync_serialize(target, user, query)
85 | case target
86 | when ArSync::Collection, ArSync::ModelBase
87 | serialized = ArSerializer.serialize target, query, context: user, use: :sync
88 | return serialized if target.is_a? ArSync::ModelBase
89 | {
90 | _sync: { keys: [ArSync.sync_key(target), ArSync.sync_key(target, user)] },
91 | ordering: target.ordering,
92 | collection: serialized
93 | }
94 | when ActiveRecord::Relation, Array
95 | ArSync.serialize target.to_a, query, user: user
96 | when ArSerializer::Serializable
97 | ArSync.serialize target, query, user: user
98 | else
99 | target
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/lib/ar_sync/instance_methods.rb:
--------------------------------------------------------------------------------
1 | module ArSync::ModelBase::InstanceMethods
2 | def sync_keys(current_user)
3 | [ArSync.sync_key(self), ArSync.sync_key(self, current_user)]
4 | end
5 |
6 | def _sync_field(current_user)
7 | { id:, keys: sync_keys(current_user) }
8 | end
9 |
10 | def _sync_notify(action)
11 | _sync_notify_parent action
12 | if self.class._sync_self?
13 | _sync_notify_self if action == :update
14 | _sync_notify_self_destroy if action == :destroy
15 | end
16 | end
17 |
18 | def _sync_current_watch_values
19 | values = {}
20 | self.class._each_sync_parent do |_, info|
21 | [*info[:watch]].each do |watch|
22 | values[watch] = watch.is_a?(Proc) ? instance_exec(&watch) : __send__(watch)
23 | end
24 | end
25 | values
26 | end
27 |
28 | def _sync_current_parents_info
29 | parents = []
30 | self.class._each_sync_parent do |parent, inverse_name:, only_to:, watch:|
31 | parent = send parent if parent.is_a? Symbol
32 | parent = instance_exec(&parent) if parent.is_a? Proc
33 | if only_to
34 | to_user = only_to.is_a?(Symbol) ? instance_eval(&only_to) : instance_exec(&only_to)
35 | parent = nil unless to_user
36 | end
37 | inverse_name = instance_exec(&inverse_name) if inverse_name.is_a? Proc
38 | owned = parent.class._sync_child_info(inverse_name).present? if parent
39 | parents << [parent, [inverse_name, to_user, owned, watch]]
40 | end
41 | parents
42 | end
43 |
44 | def _serializer_field_value(name)
45 | field = self.class._serializer_field_info name
46 | preloadeds = field.preloaders.map do |preloader|
47 | args = [[self], nil]
48 | preloader.call(*(preloader.arity < 0 ? args : args.take(preloader.arity)))
49 | end
50 | instance_exec(*preloadeds, nil, &field.data_block)
51 | end
52 |
53 | def _sync_current_belongs_to_info
54 | belongs = {}
55 | self.class._each_sync_child do |name, (type, option, data_block)|
56 | next unless type == :one
57 | option ||= {}
58 | association_name = (option[:association] || name).to_s.underscore
59 | association = self.class.reflect_on_association association_name
60 | next if association && !association.belongs_to?
61 | if association && !option[:preload] && !data_block
62 | belongs[name] = {
63 | type: association.foreign_type && self[association.foreign_type],
64 | id: self[association.foreign_key]
65 | }
66 | else
67 | belongs[name] = { value: _serializer_field_value(name) }
68 | end
69 | end
70 | belongs
71 | end
72 |
73 | def _sync_notify_parent(action)
74 | if action == :create
75 | parents = _sync_current_parents_info
76 | parents_was = parents.map { nil }
77 | elsif action == :destroy
78 | parents_was = _sync_parents_info_before_mutation
79 | return unless parents_was
80 | parents = parents_was.map { nil }
81 | else
82 | parents_was = _sync_parents_info_before_mutation
83 | return unless parents_was
84 | parents = _sync_current_parents_info
85 | column_values_was = _sync_watch_values_before_mutation || {}
86 | column_values = _sync_current_watch_values
87 | end
88 | parents_was.zip(parents).each do |(parent_was, info_was), (parent, info)|
89 | name, to_user, owned, watch = info
90 | name_was, to_user_was, owned_was = info_was
91 | if parent_was != parent || info_was != info
92 | if owned_was
93 | parent_was&._sync_notify_child_removed self, name_was, to_user_was
94 | else
95 | parent_was&._sync_notify_child_changed name_was, to_user_was
96 | end
97 | if owned
98 | parent&._sync_notify_child_added self, name, to_user
99 | else
100 | parent&._sync_notify_child_changed name, to_user
101 | end
102 | elsif parent
103 | changed = [*watch].any? do |w|
104 | column_values_was[w] != column_values[w]
105 | end
106 | parent._sync_notify_child_changed name, to_user if changed
107 | end
108 | end
109 | end
110 |
111 | def _sync_notify_child_removed(child, name, to_user)
112 | ArSync.sync_send to: self, action: :remove, model: child, path: name, to_user: to_user
113 | end
114 |
115 | def _sync_notify_child_added(child, name, to_user)
116 | ArSync.sync_send to: self, action: :add, model: child, path: name, to_user: to_user
117 | end
118 |
119 | def _sync_notify_child_changed(name, to_user)
120 | ArSync.sync_send to: self, action: :update, model: self, field: name, to_user: to_user
121 | end
122 |
123 | def _sync_notify_self
124 | belongs_was = _sync_belongs_to_info_before_mutation
125 | return unless belongs_was
126 | belongs = _sync_current_belongs_to_info
127 | belongs.each do |name, info|
128 | next if belongs_was[name] == info
129 | value = info.key?(:value) ? info[:value] : _serializer_field_value(name)
130 | _sync_notify_child_added value, name, nil if value.is_a? ArSerializer::Serializable
131 | _sync_notify_child_removed value, name, nil if value.nil?
132 | end
133 | ArSync.sync_send to: self, action: :update, model: self
134 | end
135 |
136 | def _sync_notify_self_destroy
137 | ArSync.sync_send to: self, action: :destroy, path: :_destroy, model: nil
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/ar_sync/rails.rb:
--------------------------------------------------------------------------------
1 | module ArSync
2 | module Rails
3 | class Engine < ::Rails::Engine; end
4 | end
5 |
6 | class ApiNotFound < StandardError; end
7 |
8 | on_notification do |events|
9 | events.each do |key, patch|
10 | ActionCable.server.broadcast key, patch
11 | end
12 | end
13 |
14 | module StaticJsonConcern
15 | def ar_sync_static_json(record_or_records, query)
16 | if respond_to?(ArSync.config.current_user_method)
17 | current_user = send ArSync.config.current_user_method
18 | end
19 | ArSync.serialize(record_or_records, query.as_json, user: current_user)
20 | end
21 | end
22 |
23 | ActionController::Base.class_eval do
24 | include StaticJsonConcern
25 | def action_with_compact_ar_sync_notification(&block)
26 | ArSync.with_compact_notification(&block)
27 | end
28 | around_action :action_with_compact_ar_sync_notification
29 | end
30 |
31 | class SyncSchemaBase
32 | include ArSerializer::Serializable
33 | serializer_field :__schema do
34 | ArSerializer::GraphQL::SchemaClass.new self.class
35 | end
36 | end
37 |
38 | module ApiControllerConcern
39 | extend ActiveSupport::Concern
40 |
41 | included do
42 | protect_from_forgery except: %i[sync_call static_call graphql_call]
43 | end
44 |
45 | def schema
46 | raise 'you must implement method `schema`'
47 | end
48 |
49 | def sync_call
50 | _api_call :sync do |schema, current_user, query|
51 | ArSync.sync_serialize schema, current_user, query
52 | end
53 | end
54 |
55 | def graphql_schema
56 | render plain: ArSerializer::GraphQL.definition(schema.class)
57 | end
58 |
59 | def graphql_call
60 | render json: ArSerializer::GraphQL.serialize(
61 | schema,
62 | params[:query],
63 | operation_name: params[:operationName],
64 | variables: (params[:variables] || {}).as_json,
65 | context: current_user
66 | )
67 | rescue StandardError => e
68 | render json: { error: handle_exception(e) }
69 | end
70 |
71 | def static_call
72 | _api_call :static do |schema, current_user, query|
73 | ArSerializer.serialize schema, query, context: current_user
74 | end
75 | end
76 |
77 | private
78 |
79 | def _api_call(type)
80 | if respond_to?(ArSync.config.current_user_method)
81 | current_user = send ArSync.config.current_user_method
82 | end
83 | sch = schema
84 | responses = params[:requests].map do |request|
85 | begin
86 | api_name = request[:api]
87 | raise ArSync::ApiNotFound, "#{type.to_s.capitalize} API named `#{api_name}` not configured" unless sch.class._serializer_field_info api_name
88 | query = {
89 | api_name => {
90 | as: :data,
91 | params: request[:params].as_json,
92 | attributes: request[:query].as_json
93 | }
94 | }
95 | yield schema, current_user, query
96 | rescue StandardError => e
97 | { error: handle_exception(e) }
98 | end
99 | end
100 | render json: responses
101 | end
102 |
103 | def log_internal_exception_trace(trace)
104 | if logger.formatter&.respond_to? :tags_text
105 | logger.fatal trace.join("\n#{logger.formatter.tags_text}")
106 | else
107 | logger.fatal trace.join("\n")
108 | end
109 | end
110 |
111 | def exception_trace(exception)
112 | backtrace_cleaner = request.get_header 'action_dispatch.backtrace_cleaner'
113 | wrapper = ActionDispatch::ExceptionWrapper.new backtrace_cleaner, exception
114 | trace = wrapper.application_trace
115 | trace.empty? ? wrapper.framework_trace : trace
116 | end
117 |
118 | def log_internal_exception(exception)
119 | ActiveSupport::Deprecation.silence do
120 | logger.fatal ' '
121 | logger.fatal "#{exception.class} (#{exception.message}):"
122 | log_internal_exception_trace exception.annoted_source_code if exception.respond_to?(:annoted_source_code)
123 | logger.fatal ' '
124 | log_internal_exception_trace exception_trace(exception)
125 | end
126 | end
127 |
128 | def handle_exception(exception)
129 | log_internal_exception exception
130 | backtrace = exception_trace exception unless ::Rails.env.production?
131 | case exception
132 | when ArSerializer::InvalidQuery, ArSync::ApiNotFound, ArSerializer::GraphQL::Parser::ParseError
133 | { type: 'Bad Request', message: exception.message, backtrace: backtrace }
134 | when ActiveRecord::RecordNotFound
135 | message = exception.message unless ::Rails.env.production?
136 | { type: 'Record Not Found', message: message.to_s, backtrace: backtrace }
137 | else
138 | message = "#{exception.class} (#{exception.message})" unless ::Rails.env.production?
139 | { type: 'Internal Server Error', message: message.to_s, backtrace: backtrace }
140 | end
141 | end
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/lib/ar_sync/type_script.rb:
--------------------------------------------------------------------------------
1 | module ArSync::TypeScript
2 | def self.generate_typed_files(api_class, dir:, comment: nil)
3 | {
4 | 'types.ts' => generate_type_definition(api_class),
5 | 'ArSyncModel.ts' => generate_model_script,
6 | 'ArSyncApi.ts' => generate_api_script,
7 | 'hooks.ts' => generate_hooks_script,
8 | 'DataTypeFromRequest.ts' => generate_type_util_script
9 | }.each { |file, code| File.write File.join(dir, file), "#{comment}#{code}" }
10 | end
11 |
12 | def self.generate_type_definition(api_class)
13 | types = ArSerializer::TypeScript.related_serializer_types([api_class]).reject { |t| t.type == api_class }
14 | [
15 | types.map { |t| data_type_definition t },
16 | types.map { |t| query_type_definition t },
17 | request_type_definition(api_class)
18 | ].join "\n"
19 | end
20 |
21 | def self.request_type_definition(api_class)
22 | type = ArSerializer::GraphQL::TypeClass.from api_class
23 | definitions = []
24 | request_types = {}
25 | type.fields.each do |field|
26 | association_type = field.type.association_type
27 | next unless association_type
28 | prefix = 'Class' if field.name.match?(/\A[A-Z]/) # for class reload query
29 | request_type_name = "Type#{prefix}#{field.name.camelize}Request"
30 | request_types[field.name] = request_type_name
31 | multiple = field.type.is_a? ArSerializer::GraphQL::ListTypeClass
32 | definitions << <<~CODE
33 | export interface #{request_type_name} {
34 | api: '#{field.name}'
35 | params?: #{field.args_ts_type}
36 | query: Type#{association_type.name}Query
37 | _meta?: { data: Type#{field.type.association_type.name}#{'[]' if multiple} }
38 | }
39 | CODE
40 | end
41 | [
42 | 'export type TypeRequest = ',
43 | request_types.values.map { |value| " | #{value}" },
44 | 'export type ApiNameRequests = {',
45 | request_types.map { |key, value| " #{key}: #{value}" },
46 | '}',
47 | definitions
48 | ].join("\n")
49 | end
50 |
51 | def self.generate_model_script
52 | <<~CODE
53 | import { TypeRequest } from './types'
54 | import DataTypeFromRequest from './DataTypeFromRequest'
55 | import { default as ArSyncModelBase } from 'ar_sync/core/ArSyncModel'
56 | declare class ArSyncModel extends ArSyncModelBase> {
57 | constructor(r: R, option?: { immutable: boolean })
58 | }
59 | export default ArSyncModelBase as typeof ArSyncModel
60 | CODE
61 | end
62 |
63 | def self.generate_api_script
64 | <<~CODE
65 | import { TypeRequest } from './types'
66 | import DataTypeFromRequest from './DataTypeFromRequest'
67 | import ArSyncApi from 'ar_sync/core/ArSyncApi'
68 | export function fetch(request: R) {
69 | return ArSyncApi.fetch(request) as Promise>
70 | }
71 | CODE
72 | end
73 |
74 | def self.generate_type_util_script
75 | <<~CODE
76 | import { TypeRequest, ApiNameRequests } from './types'
77 | import { DataTypeFromRequest as DataTypeFromRequestPair } from 'ar_sync/core/DataType'
78 | export type NeverMatchArgument = { __nevermatch: never }
79 | type DataTypeFromRequest = NeverMatchArgument extends R ? never : R extends TypeRequest ? DataTypeFromRequestPair : never
80 | export default DataTypeFromRequest
81 | CODE
82 | end
83 |
84 | def self.generate_hooks_script
85 | <<~CODE
86 | import { useState, useEffect, useMemo, useRef } from 'react'
87 | import { TypeRequest } from './types'
88 | import DataTypeFromRequest, { NeverMatchArgument } from './DataTypeFromRequest'
89 | import { initializeHooks, useArSyncModel as useArSyncModelBase, useArSyncFetch as useArSyncFetchBase } from 'ar_sync/core/hooks'
90 | initializeHooks({ useState, useEffect, useMemo, useRef })
91 | export function useArSyncModel(request: R | null) {
92 | return useArSyncModelBase>(request as TypeRequest)
93 | }
94 | export function useArSyncFetch(request: R | null) {
95 | return useArSyncFetchBase>(request as TypeRequest)
96 | }
97 | CODE
98 | end
99 |
100 | def self.query_type_definition(type)
101 | field_definitions = type.fields.map do |field|
102 | association_type = field.type.association_type
103 | if association_type
104 | qname = "Type#{association_type.name}Query"
105 | if field.args.empty?
106 | "#{field.name}?: true | #{qname} | { attributes?: #{qname} }"
107 | else
108 | "#{field.name}?: true | #{qname} | { params: #{field.args_ts_type}; attributes?: #{qname} }"
109 | end
110 | else
111 | "#{field.name}?: true"
112 | end
113 | end
114 | field_definitions << "'*'?: true"
115 | query_type_name = "Type#{type.name}Query"
116 | base_query_type_name = "Type#{type.name}QueryBase"
117 | <<~TYPE
118 | export type #{query_type_name} = keyof (#{base_query_type_name}) | Readonly<(keyof (#{base_query_type_name}))[]> | #{base_query_type_name}
119 | export interface #{base_query_type_name} {
120 | #{field_definitions.map { |line| " #{line}" }.join("\n")}
121 | }
122 | TYPE
123 | end
124 |
125 | def self.data_type_definition(type)
126 | field_definitions = []
127 | type.fields.each do |field|
128 | field_definitions << "#{field.name}: #{field.type.ts_type}"
129 | end
130 | field_definitions << "_meta?: { name: '#{type.name}'; query: Type#{type.name}QueryBase }"
131 | <<~TYPE
132 | export interface Type#{type.name} {
133 | #{field_definitions.map { |line| " #{line}" }.join("\n")}
134 | }
135 | TYPE
136 | end
137 | end
138 |
--------------------------------------------------------------------------------
/lib/ar_sync/version.rb:
--------------------------------------------------------------------------------
1 | module ArSync
2 | VERSION = '1.1.2'
3 | end
4 |
--------------------------------------------------------------------------------
/lib/generators/ar_sync/install/install_generator.rb:
--------------------------------------------------------------------------------
1 | module ArSync
2 | class InstallGenerator < ::Rails::Generators::Base
3 | def create_schema_class
4 | create_file 'app/models/sync_schema.rb', <<~CODE
5 | class SyncSchema < ArSync::SyncSchemaBase
6 | # serializer_field :profile, type: User do |current_user|
7 | # current_user
8 | # end
9 |
10 | # serializer_field :post, type: Post do |current_user, id:|
11 | # Post.where(current_user_can_access).find_by id: id
12 | # end
13 |
14 | # Reload API for all types should be defined here.
15 |
16 | # serializer_field :User do |current_user, ids:|
17 | # User.where(current_user_can_access).where id: ids
18 | # end
19 |
20 | # serializer_field :Post do |current_user, ids:|
21 | # Post.where(current_user_can_access).where id: ids
22 | # end
23 |
24 | # serializer_field :Comment do |current_user, ids:|
25 | # Comment.where(current_user_can_access).where id: ids
26 | # end
27 | end
28 | CODE
29 | end
30 |
31 | def create_api_controller
32 | create_file 'app/controllers/sync_api_controller.rb', <<~CODE
33 | class SyncApiController < ApplicationController
34 | include ArSync::ApiControllerConcern
35 | def schema
36 | SyncSchema.new
37 | end
38 | end
39 | CODE
40 | end
41 |
42 | def create_config
43 | create_file 'config/initializers/ar_sync.rb', <<~CODE
44 | ActiveRecord::Base.include ArSync::ModelBase
45 | ArSync.configure do |config|
46 | config.current_user_method = :current_user
47 | config.key_prefix = 'ar_sync_'
48 | config.key_secret = '#{SecureRandom.hex}'
49 | config.key_expires_in = 30.seconds
50 | end
51 | CODE
52 | end
53 |
54 | def create_sync_channel
55 | create_file 'app/channels/sync_channel.rb', <<~CODE
56 | class SyncChannel < ApplicationCable::Channel
57 | def subscribed
58 | key = ArSync.validate_expiration params[:key]
59 | stream_from key if key
60 | end
61 | end
62 | CODE
63 | end
64 |
65 | def setup_routes
66 | inject_into_file(
67 | 'config/routes.rb',
68 | "\n post '/sync_api', to: 'sync_api#sync_call'" +
69 | "\n post '/static_api', to: 'sync_api#static_call'" +
70 | "\n post '/graphql', to: 'sync_api#graphql_call'",
71 | after: 'Rails.application.routes.draw do'
72 | )
73 | end
74 |
75 | def setup_js
76 | inject_into_file(
77 | 'app/assets/javascripts/application.js',
78 | [
79 | '//= require ar_sync',
80 | '//= require action_cable',
81 | '//= require ar_sync_action_cable_adapter',
82 | 'ArSyncModel.setConnectionAdapter(new ArSyncActionCableAdapter(ActionCable))'
83 | ].join("\n") + "\n",
84 | before: '//= require_tree .'
85 | )
86 | end
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/lib/generators/ar_sync/types/types_generator.rb:
--------------------------------------------------------------------------------
1 | require 'shellwords'
2 | module ArSync
3 | class TypesGenerator < ::Rails::Generators::Base
4 | argument :output_dir, type: :string, required: true
5 | argument :schema_class_name, type: :string, required: false
6 | def generate_typescript_files
7 | dir = ::Rails.root.join output_dir
8 | schema_class = (schema_class_name || 'SyncSchema').constantize
9 | args = [output_dir, *schema_class_name].map { |a| Shellwords.escape a }
10 | comment = "// generated by: rails generate ar_sync:types #{args.join(' ')}\n\n"
11 | ArSync::TypeScript.generate_typed_files schema_class, dir: dir, comment: comment
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ar_sync",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "build": "tsc"
6 | },
7 | "dependencies": {},
8 | "devDependencies": {
9 | "react": "^16.8.6",
10 | "@types/react": "^16.8.6",
11 | "actioncable": "^5.2.0",
12 | "@types/actioncable": "^5.2.0",
13 | "eslint": "^5.16.0",
14 | "typescript": "3.9.5",
15 | "@typescript-eslint/eslint-plugin": "^1.6.0",
16 | "@typescript-eslint/parser": "^1.6.0"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/core/ActionCableAdapter.ts:
--------------------------------------------------------------------------------
1 | import ConnectionAdapter from './ConnectionAdapter'
2 |
3 | declare module ActionCable {
4 | function createConsumer(): Cable
5 | interface Cable {
6 | subscriptions: Subscriptions
7 | }
8 | interface CreateMixin {
9 | connected: () => void
10 | disconnected: () => void
11 | received: (obj: any) => void
12 | }
13 | interface ChannelNameWithParams {
14 | channel: string
15 | [key: string]: any
16 | }
17 | interface Subscriptions {
18 | create(channel: ChannelNameWithParams, obj: CreateMixin): Channel
19 | }
20 | interface Channel {
21 | unsubscribe(): void;
22 | perform(action: string, data: {}): void;
23 | send(data: any): boolean;
24 | }
25 | }
26 |
27 | export default class ActionCableAdapter implements ConnectionAdapter {
28 | connected: boolean
29 | _cable: ActionCable.Cable
30 | actionCableClass: typeof ActionCable
31 | constructor(actionCableClass: typeof ActionCable) {
32 | this.connected = true
33 | this.actionCableClass = actionCableClass
34 | this.subscribe(Math.random().toString(), () => {})
35 | }
36 | subscribe(key: string, received: (data: any) => void) {
37 | const disconnected = () => {
38 | if (!this.connected) return
39 | this.connected = false
40 | this.ondisconnect()
41 | }
42 | const connected = () => {
43 | if (this.connected) return
44 | this.connected = true
45 | this.onreconnect()
46 | }
47 | if (!this._cable) this._cable = this.actionCableClass.createConsumer()
48 | return this._cable.subscriptions.create(
49 | { channel: 'SyncChannel', key },
50 | { received, disconnected, connected }
51 | )
52 | }
53 | ondisconnect() {}
54 | onreconnect() {}
55 | }
56 |
--------------------------------------------------------------------------------
/src/core/ArSyncApi.ts:
--------------------------------------------------------------------------------
1 | async function apiBatchFetch(endpoint: string, requests: object[]) {
2 | const headers = {
3 | 'Accept': 'application/json',
4 | 'Content-Type': 'application/json'
5 | }
6 | const body = JSON.stringify({ requests })
7 | const option = { credentials: 'include', method: 'POST', headers, body } as const
8 | if (ArSyncApi.domain) endpoint = ArSyncApi.domain + endpoint
9 | const res = await fetch(endpoint, option)
10 | if (res.status === 200) return res.json()
11 | throw new Error(res.statusText)
12 | }
13 | type FetchError = {
14 | type: string
15 | message: string
16 | retry: boolean
17 | }
18 | interface PromiseCallback {
19 | resolve: (data: any) => void
20 | reject: (error: FetchError) => void
21 | }
22 |
23 | type Request = { api: string; params?: any; query: any; id?: number }
24 | class ApiFetcher {
25 | endpoint: string
26 | batches: [object, PromiseCallback][] = []
27 | batchFetchTimer: number | null = null
28 | constructor(endpoint: string) {
29 | this.endpoint = endpoint
30 | }
31 | fetch(request: Request) {
32 | if (request.id != null) {
33 | return new Promise((resolve, reject) => {
34 | this.fetch({ api: request.api, params: { ids: [request.id] }, query: request.query }).then((result: any[]) => {
35 | if (result[0]) resolve(result[0])
36 | else reject({ type: 'Not Found', retry: false })
37 | }).catch(reject)
38 | })
39 | }
40 | return new Promise((resolve, reject) => {
41 | this.batches.push([request, { resolve, reject }])
42 | if (this.batchFetchTimer) return
43 | this.batchFetchTimer = setTimeout(() => {
44 | this.batchFetchTimer = null
45 | const compacts: { [key: string]: PromiseCallback[] } = {}
46 | const requests: object[] = []
47 | const callbacksList: PromiseCallback[][] = []
48 | for (const batch of this.batches) {
49 | const request = batch[0]
50 | const callback = batch[1]
51 | const key = JSON.stringify(request)
52 | if (compacts[key]) {
53 | compacts[key].push(callback)
54 | } else {
55 | requests.push(request)
56 | callbacksList.push(compacts[key] = [callback])
57 | }
58 | }
59 | this.batches = []
60 | ArSyncApi._batchFetch(this.endpoint, requests).then((results) => {
61 | for (const i in callbacksList) {
62 | const result = results[i]
63 | const callbacks = callbacksList[i]
64 | for (const callback of callbacks) {
65 | if (result.data !== undefined) {
66 | callback.resolve(result.data)
67 | } else {
68 | const error = result.error || { type: 'Unknown Error' }
69 | callback.reject({ ...error, retry: false })
70 | }
71 | }
72 | }
73 | }).catch(e => {
74 | const error = { type: e.name, message: e.message, retry: true }
75 | for (const callbacks of callbacksList) {
76 | for (const callback of callbacks) callback.reject(error)
77 | }
78 | })
79 | }, 16)
80 | })
81 | }
82 | }
83 |
84 | const staticFetcher = new ApiFetcher('/static_api')
85 | const syncFetcher = new ApiFetcher('/sync_api')
86 | const ArSyncApi = {
87 | domain: null as string | null,
88 | _batchFetch: apiBatchFetch,
89 | fetch: (request: Request) => staticFetcher.fetch(request),
90 | syncFetch: (request: Request) => syncFetcher.fetch(request),
91 | }
92 | export default ArSyncApi
93 |
--------------------------------------------------------------------------------
/src/core/ArSyncModel.ts:
--------------------------------------------------------------------------------
1 | import { ArSyncStore, Request } from './ArSyncStore'
2 | import ArSyncConnectionManager from './ConnectionManager'
3 | import ConnectionAdapter from './ConnectionAdapter'
4 |
5 | type Path = Readonly<(string | number)[]>
6 | interface Change { path: Path; value: any }
7 | type ChangeCallback = (change: Change) => void
8 | type LoadCallback = () => void
9 | type ConnectionCallback = (status: boolean) => void
10 | type SubscriptionType = 'load' | 'change' | 'connection' | 'destroy'
11 | type SubscriptionCallback = ChangeCallback | LoadCallback | ConnectionCallback
12 | type ArSyncModelRef = { key: string; count: number; timer: number | null; model: ArSyncStore }
13 |
14 | type PathFirst> = ((...args: P) => void) extends (first: infer First, ...other: any) => void ? First : never
15 |
16 | type PathRest = U extends Readonly ?
17 | ((...args: U) => any) extends (head: any, ...args: infer T) => any
18 | ? U extends Readonly<[any, any, ...any[]]> ? T : never
19 | : never
20 | : never;
21 |
22 | type DigResult> =
23 | Data extends null | undefined ? Data :
24 | PathFirst extends never ? Data :
25 | PathFirst
extends keyof Data ?
26 | (Data extends Readonly ? undefined : never) | {
27 | 0: Data[PathFirst];
28 | 1: DigResult], PathRest>
29 | }[PathRest
extends never ? 0 : 1]
30 | : undefined
31 |
32 | export default class ArSyncModel {
33 | private _ref: ArSyncModelRef
34 | private _listenerSerial: number
35 | private _listeners
36 | complete = false
37 | notfound?: boolean
38 | destroyed = false
39 | connected: boolean
40 | data: T | null
41 | static _cache: { [key: string]: { key: string; count: number; timer: number | null; model } } = {}
42 | static cacheTimeout: number = 10 * 1000
43 | constructor(request: Request, option?: { immutable: boolean }) {
44 | this._ref = ArSyncModel.retrieveRef(request, option)
45 | this._listenerSerial = 0
46 | this._listeners = {}
47 | this.connected = ArSyncStore.connectionManager.networkStatus
48 | const setData = () => {
49 | this.data = this._ref.model.data
50 | this.complete = this._ref.model.complete
51 | this.notfound = this._ref.model.notfound
52 | this.destroyed = this._ref.model.destroyed
53 | }
54 | setData()
55 | this.subscribe('load', setData)
56 | this.subscribe('change', setData)
57 | this.subscribe('destroy', setData)
58 | this.subscribe('connection', (status: boolean) => {
59 | this.connected = status
60 | })
61 | }
62 | onload(callback: LoadCallback) {
63 | this.subscribeOnce('load', callback)
64 | }
65 | subscribeOnce(event: SubscriptionType, callback: SubscriptionCallback) {
66 | const subscription = this.subscribe(event, (arg) => {
67 | (callback as (arg: any) => void)(arg)
68 | subscription.unsubscribe()
69 | })
70 | return subscription
71 | }
72 | dig(path: P) {
73 | return ArSyncModel.digData(this.data, path)
74 | }
75 | static digData(data: Data, path: P): DigResult {
76 | function dig(data: Data, path: Path) {
77 | if (path.length === 0) return data as any
78 | if (data == null) return data
79 | const key = path[0]
80 | const other = path.slice(1)
81 | if (Array.isArray(data)) {
82 | return this.digData(data.find(el => el.id === key), other)
83 | } else {
84 | return this.digData(data[key], other)
85 | }
86 | }
87 | return dig(data, path)
88 | }
89 | subscribe(event: SubscriptionType, callback: SubscriptionCallback): { unsubscribe: () => void } {
90 | const id = this._listenerSerial++
91 | const subscription = this._ref.model.subscribe(event, callback)
92 | let unsubscribed = false
93 | const unsubscribe = () => {
94 | unsubscribed = true
95 | subscription.unsubscribe()
96 | delete this._listeners[id]
97 | }
98 | if (this.complete) {
99 | if (event === 'load') setTimeout(() => {
100 | if (!unsubscribed) (callback as LoadCallback)()
101 | }, 0)
102 | if (event === 'change') setTimeout(() => {
103 | if (!unsubscribed) (callback as ChangeCallback)({ path: [], value: this.data })
104 | }, 0)
105 | }
106 | return this._listeners[id] = { unsubscribe }
107 | }
108 | release() {
109 | for (const id in this._listeners) this._listeners[id].unsubscribe()
110 | this._listeners = {}
111 | ArSyncModel._detach(this._ref)
112 | }
113 | static retrieveRef(
114 | request: Request,
115 | option?: { immutable: boolean }
116 | ): ArSyncModelRef {
117 | const key = JSON.stringify([request, option])
118 | let ref = this._cache[key]
119 | if (!ref) {
120 | const model = new ArSyncStore(request, option)
121 | ref = this._cache[key] = { key, count: 0, timer: null, model }
122 | }
123 | this._attach(ref)
124 | return ref
125 | }
126 | static _detach(ref) {
127 | ref.count--
128 | const timeout = this.cacheTimeout
129 | if (ref.count !== 0) return
130 | const timedout = () => {
131 | ref.model.release()
132 | delete this._cache[ref.key]
133 | }
134 | if (timeout) {
135 | ref.timer = setTimeout(timedout, timeout)
136 | } else {
137 | timedout()
138 | }
139 | }
140 | private static _attach(ref) {
141 | ref.count++
142 | if (ref.timer) clearTimeout(ref.timer)
143 | }
144 | static setConnectionAdapter(adapter: ConnectionAdapter) {
145 | ArSyncStore.connectionManager = new ArSyncConnectionManager(adapter)
146 | }
147 | static waitForLoad(...models: ArSyncModel<{}>[]) {
148 | return new Promise((resolve) => {
149 | let count = 0
150 | for (const model of models) {
151 | model.onload(() => {
152 | count++
153 | if (models.length == count) resolve(models)
154 | })
155 | }
156 | })
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/core/ArSyncStore.ts:
--------------------------------------------------------------------------------
1 | import ArSyncApi from './ArSyncApi'
2 | export type Request = { api: string; query: any; params?: any; id?: IDType }
3 |
4 | type IDType = number | string
5 |
6 | class ModelBatchRequest {
7 | timer: number | null = null
8 | apiRequests = new Map void
17 | reject: (error?: any) => void
18 | }[]
19 | }>
20 | }
21 | >
22 | >()
23 | fetch(api: string, query, id: IDType) {
24 | this.setTimer()
25 | return new Promise((resolve, reject) => {
26 | const queryJSON = JSON.stringify(query)
27 | let apiRequest = this.apiRequests.get(api)
28 | if (!apiRequest) this.apiRequests.set(api, apiRequest = new Map())
29 | let queryRequests = apiRequest.get(queryJSON)
30 | if (!queryRequests) apiRequest.set(queryJSON, queryRequests = { query, requests: new Map() })
31 | let request = queryRequests.requests.get(id)
32 | if (!request) queryRequests.requests.set(id, request = { id, callbacks: [] })
33 | request.callbacks.push({ resolve, reject })
34 | })
35 | }
36 | batchFetch() {
37 | this.apiRequests.forEach((apiRequest, api) => {
38 | apiRequest.forEach(({ query, requests }) => {
39 | const ids = Array.from(requests.keys())
40 | ArSyncApi.syncFetch({ api, query, params: { ids } }).then((models: any[]) => {
41 | for (const model of models) {
42 | const req = requests.get(model._sync.id)
43 | if (req) req.model = model
44 | }
45 | requests.forEach(({ model, callbacks }) => {
46 | callbacks.forEach(cb => cb.resolve(model))
47 | })
48 | }).catch(e => {
49 | requests.forEach(({ callbacks }) => {
50 | callbacks.forEach(cb => cb.reject(e))
51 | })
52 | })
53 | })
54 | })
55 | this.apiRequests.clear()
56 | }
57 | setTimer() {
58 | if (this.timer) return
59 | this.timer = setTimeout(() => {
60 | this.timer = null
61 | this.batchFetch()
62 | }, 20)
63 | }
64 | }
65 | const modelBatchRequest = new ModelBatchRequest
66 |
67 | type ParsedQuery = {
68 | attributes: Record
69 | as?: string
70 | params: any
71 | } | {}
72 |
73 | type SyncField = { id: IDType; keys: string[] }
74 | type Unsubscribable = { unsubscribe: () => void }
75 |
76 | class ArSyncContainerBase {
77 | data
78 | listeners: Unsubscribable[] = []
79 | networkSubscriber?: Unsubscribable
80 | parentModel: ArSyncRecord | ArSyncCollection | null = null
81 | parentKey
82 | children: ArSyncContainerBase[] | { [key: string]: ArSyncContainerBase | null }
83 | onConnectionChange?: (status: boolean) => void
84 | replaceData(_data, _parentSyncKeys?) {}
85 | initForReload(request) {
86 | this.networkSubscriber = ArSyncStore.connectionManager.subscribeNetwork((state) => {
87 | if (!state) {
88 | if (this.onConnectionChange) this.onConnectionChange(false)
89 | return
90 | }
91 | if (request.id != null) {
92 | modelBatchRequest.fetch(request.api, request.query, request.id).then(data => {
93 | if (this.data && data) {
94 | this.replaceData(data)
95 | if (this.onConnectionChange) this.onConnectionChange(true)
96 | if (this.onChange) this.onChange([], this.data)
97 | }
98 | })
99 | } else {
100 | ArSyncApi.syncFetch(request).then(data => {
101 | if (this.data && data) {
102 | this.replaceData(data)
103 | if (this.onConnectionChange) this.onConnectionChange(true)
104 | if (this.onChange) this.onChange([], this.data)
105 | }
106 | }).catch(e => {
107 | console.error(`failed to reload. ${e}`)
108 | })
109 | }
110 | })
111 | }
112 | release() {
113 | if (this.networkSubscriber) this.networkSubscriber.unsubscribe()
114 | this.unsubscribeAll()
115 | for (const child of Object.values(this.children)) {
116 | if (child) child.release()
117 | }
118 | this.data = null
119 | }
120 | onChange(path, data) {
121 | if (this.parentModel) this.parentModel.onChange([this.parentKey, ...path], data)
122 | }
123 | subscribe(key: string, listener) {
124 | this.listeners.push(ArSyncStore.connectionManager.subscribe(key, listener))
125 | }
126 | unsubscribeAll() {
127 | for (const l of this.listeners) l.unsubscribe()
128 | this.listeners = []
129 | }
130 | static compactQueryAttributes(query: ParsedQuery) {
131 | function compactAttributes(attributes: Record): [ParsedQuery, boolean] {
132 | const attrs = {}
133 | const keys: string[] = []
134 | for (const key in attributes) {
135 | const c = compactQuery(attributes[key])
136 | if (c === true) {
137 | keys.push(key)
138 | } else {
139 | attrs[key] = c
140 | }
141 | }
142 | if (Object.keys(attrs).length === 0) {
143 | if (keys.length === 0) return [true, false]
144 | if (keys.length === 1) return [keys[0], false]
145 | return [keys, false]
146 | }
147 | const needsEscape = attrs['attributes'] || attrs['params'] || attrs['as']
148 | if (keys.length === 0) return [attrs, needsEscape]
149 | return [[...keys, attrs], needsEscape]
150 | }
151 | function compactQuery(query: ParsedQuery): ParsedQuery {
152 | if (!('attributes' in query)) return true
153 | const { as, params } = query
154 | const [attributes, needsEscape] = compactAttributes(query.attributes)
155 | if (as == null && params == null){
156 | if (needsEscape) return { attributes }
157 | return attributes
158 | }
159 | const result: { as?: string; params?: any; attributes?: any } = {}
160 | if (as) result.as = as
161 | if (params) result.params = params
162 | if (attributes !== true) result.attributes = attributes
163 | return result
164 | }
165 | const result = compactQuery(query)
166 | if (typeof result === 'object' && 'attributes' in result) return result.attributes
167 | return result === true ? {} : result
168 | }
169 | static parseQuery(query, attrsonly: true): Record
170 | static parseQuery(query): ParsedQuery
171 | static parseQuery(query, attrsonly?: true) {
172 | const attributes: Record = {}
173 | let column = null
174 | let params = null
175 | if (!query) query = []
176 | if (query.constructor !== Array) query = [query]
177 | for (const arg of query) {
178 | if (typeof(arg) === 'string') {
179 | attributes[arg] = {}
180 | } else if (typeof(arg) === 'object') {
181 | for (const key in arg){
182 | const value = arg[key]
183 | if (attrsonly) {
184 | attributes[key] = this.parseQuery(value)
185 | continue
186 | }
187 | if (key === 'attributes') {
188 | const child = this.parseQuery(value, true)
189 | for (const k in child) attributes[k] = child[k]
190 | } else if (key === 'as') {
191 | column = value
192 | } else if (key === 'params') {
193 | params = value
194 | } else {
195 | attributes[key] = this.parseQuery(value)
196 | }
197 | }
198 | }
199 | }
200 | if (attrsonly) return attributes
201 | return { attributes, as: column, params }
202 | }
203 | static load({ api, id, params, query }: Request, root: ArSyncStore) {
204 | const parsedQuery = ArSyncRecord.parseQuery(query)
205 | const compactQueryAttributes = ArSyncRecord.compactQueryAttributes(parsedQuery)
206 | if (id != null) {
207 | return modelBatchRequest.fetch(api, compactQueryAttributes, id).then(data => {
208 | if (!data) throw { retry: false }
209 | const request = { api, id, query: compactQueryAttributes }
210 | return new ArSyncRecord(parsedQuery, data, request, root, null, null)
211 | })
212 | } else {
213 | const request = { api, query: compactQueryAttributes, params }
214 | return ArSyncApi.syncFetch(request).then((response: any) => {
215 | if (!response) {
216 | throw { retry: false }
217 | } else if (response.collection && response.order && response._sync) {
218 | return new ArSyncCollection(response._sync.keys, 'collection', parsedQuery, response, request, root, null, null)
219 | } else if (response instanceof Array) {
220 | return new ArSyncCollection([], '', parsedQuery, response, request, root, null, null)
221 | } else {
222 | return new ArSyncRecord(parsedQuery, response, request, root, null, null)
223 | }
224 | })
225 | }
226 | }
227 | }
228 |
229 | type NotifyData = {
230 | action: 'add' | 'remove' | 'update'
231 | class: string
232 | id: IDType
233 | field?: string
234 | }
235 |
236 | class ArSyncRecord extends ArSyncContainerBase {
237 | id: IDType
238 | root: ArSyncStore
239 | query
240 | queryAttributes
241 | data
242 | syncKeys: string[]
243 | children: { [key: string]: ArSyncContainerBase | null }
244 | paths: string[]
245 | reloadQueryCache
246 | rootRecord: boolean
247 | fetching = new Set()
248 | constructor(query, data, request, root: ArSyncStore, parentModel: ArSyncRecord | ArSyncCollection | null, parentKey: string | number | null) {
249 | super()
250 | this.root = root
251 | if (request) this.initForReload(request)
252 | this.query = query
253 | this.queryAttributes = query.attributes || {}
254 | this.data = {}
255 | this.children = {}
256 | this.rootRecord = !parentModel
257 | this.id = data._sync.id
258 | this.syncKeys = data._sync.keys
259 | this.replaceData(data)
260 | this.parentModel = parentModel
261 | this.parentKey = parentKey
262 | }
263 | replaceData(data: { _sync: SyncField }) {
264 | this.id = data._sync.id
265 | this.syncKeys = data._sync.keys
266 | this.unsubscribeAll()
267 | this.paths = []
268 | for (const key in this.queryAttributes) {
269 | const subQuery = this.queryAttributes[key]
270 | const aliasName = subQuery.as || key
271 | const subData = data[aliasName]
272 | const child = this.children[aliasName]
273 | if (key === '_sync') continue
274 | if (subData instanceof Array || (subData && subData.collection && subData.order && subData._sync)) {
275 | if (child) {
276 | child.replaceData(subData, this.syncKeys)
277 | } else {
278 | const collection = new ArSyncCollection(this.syncKeys, key, subQuery, subData, null, this.root, this, aliasName)
279 | this.mark()
280 | this.children[aliasName] = collection
281 | this.data[aliasName] = collection.data
282 | }
283 | } else {
284 | if (subQuery.attributes && Object.keys(subQuery.attributes).length > 0) this.paths.push(key)
285 | if (subData && subData._sync) {
286 | if (child) {
287 | child.replaceData(subData)
288 | } else {
289 | const model = new ArSyncRecord(subQuery, subData, null, this.root, this, aliasName)
290 | this.mark()
291 | this.children[aliasName] = model
292 | this.data[aliasName] = model.data
293 | }
294 | } else {
295 | if(child) {
296 | child.release()
297 | delete this.children[aliasName]
298 | }
299 | if (this.data[aliasName] !== subData) {
300 | this.mark()
301 | this.data[aliasName] = subData
302 | }
303 | }
304 | }
305 | }
306 | if (this.queryAttributes['*']) {
307 | for (const key in data) {
308 | if (key === '_sync') continue
309 | if (!this.queryAttributes[key] && this.data[key] !== data[key]) {
310 | this.mark()
311 | this.data[key] = data[key]
312 | }
313 | }
314 | }
315 | this.subscribeAll()
316 | }
317 | onNotify(notifyData: NotifyData, path?: string) {
318 | const { action, class: className, id } = notifyData
319 | const query = path && this.queryAttributes[path]
320 | const aliasName = (query && query.as) || path;
321 | if (action === 'remove') {
322 | const child = this.children[aliasName]
323 | this.fetching.delete(`${aliasName}:${id}`) // To cancel consumeAdd
324 | if (child) child.release()
325 | this.children[aliasName] = null
326 | this.mark()
327 | this.data[aliasName] = null
328 | this.onChange([aliasName], null)
329 | } else if (action === 'add') {
330 | const child = this.children[aliasName]
331 | if (child instanceof ArSyncRecord && child.id === id) return
332 | const fetchKey = `${aliasName}:${id}`
333 | this.fetching.add(fetchKey)
334 | modelBatchRequest.fetch(className, ArSyncRecord.compactQueryAttributes(query), id).then(data => {
335 | // Record already removed
336 | if (!this.fetching.has(fetchKey)) return
337 |
338 | this.fetching.delete(fetchKey)
339 | if (!data || !this.data) return
340 | const model = new ArSyncRecord(query, data, null, this.root, this, aliasName)
341 | const child = this.children[aliasName]
342 | if (child) child.release()
343 | this.children[aliasName] = model
344 | this.mark()
345 | this.data[aliasName] = model.data
346 | this.onChange([aliasName], model.data)
347 | }).catch(e => {
348 | console.error(`failed to load ${className}:${id} ${e}`)
349 | })
350 | } else {
351 | const { field } = notifyData
352 | const query = field ? this.patchQuery(field) : this.reloadQuery()
353 | if (!query) return
354 | modelBatchRequest.fetch(className, query, id).then(data => {
355 | if (this.data) this.update(data)
356 | }).catch(e => {
357 | console.error(`failed to load patch ${className}:${id} ${e}`)
358 | })
359 | }
360 | }
361 | subscribeAll() {
362 | const callback = data => this.onNotify(data)
363 | for (const key of this.syncKeys) {
364 | this.subscribe(key, callback)
365 | }
366 | for (const path of this.paths) {
367 | const pathCallback = data => this.onNotify(data, path)
368 | for (const key of this.syncKeys) this.subscribe(key + path, pathCallback)
369 | }
370 | if (this.rootRecord) {
371 | const key = this.syncKeys[0]
372 | if (key) this.subscribe(key + '_destroy', () => this.root.handleDestroy())
373 | }
374 | }
375 | patchQuery(key: string) {
376 | const subQuery = this.queryAttributes[key]
377 | if (subQuery) return { [key]: subQuery }
378 | }
379 | reloadQuery() {
380 | if (this.reloadQueryCache) return this.reloadQueryCache
381 | let arrayQuery = [] as string[] | null
382 | const hashQuery = {}
383 | for (const key in this.queryAttributes) {
384 | if (key === '_sync') continue
385 | const val = this.queryAttributes[key]
386 | if (!val || !val.attributes) {
387 | arrayQuery?.push(key)
388 | hashQuery[key] = true
389 | } else if (!val.params && Object.keys(val.attributes).length === 0) {
390 | arrayQuery = null
391 | hashQuery[key] = val
392 | }
393 | }
394 | return this.reloadQueryCache = arrayQuery || hashQuery
395 | }
396 | update(data) {
397 | for (const key in data) {
398 | if (key === '_sync') continue
399 | const subQuery = this.queryAttributes[key]
400 | if (subQuery && subQuery.attributes && Object.keys(subQuery.attributes).length > 0) continue
401 | if (this.data[key] === data[key]) continue
402 | this.mark()
403 | this.data[key] = data[key]
404 | this.onChange([key], data[key])
405 | }
406 | }
407 | markAndSet(key: string, data: any) {
408 | this.mark()
409 | this.data[key] = data
410 | }
411 | mark() {
412 | if (!this.root || !this.root.immutable || !Object.isFrozen(this.data)) return
413 | this.data = { ...this.data }
414 | this.root.mark(this.data)
415 | if (this.parentModel && this.parentKey) (this.parentModel as { markAndSet(key: string | number, data: any): any }).markAndSet(this.parentKey, this.data)
416 | }
417 | }
418 |
419 | type Ordering = { first?: number; last?: number; orderBy: string; direction: 'asc' | 'desc' }
420 | class ArSyncCollection extends ArSyncContainerBase {
421 | root: ArSyncStore
422 | path: string
423 | ordering: Ordering = { orderBy: 'id', direction: 'asc' }
424 | query
425 | queryAttributes
426 | compactQueryAttributes
427 | syncKeys: string[]
428 | data: any[]
429 | children: ArSyncRecord[]
430 | aliasOrderKey = 'id'
431 | fetching = new Set()
432 | constructor(parentSyncKeys: string[], path: string, query, data: any[], request, root: ArSyncStore, parentModel: ArSyncRecord | null, parentKey: string | null){
433 | super()
434 | this.root = root
435 | this.path = path
436 | this.query = query
437 | this.queryAttributes = query.attributes || {}
438 | this.compactQueryAttributes = ArSyncRecord.compactQueryAttributes(query)
439 | if (request) this.initForReload(request)
440 | if (query.params) {
441 | this.setOrdering(query.params)
442 | }
443 | this.data = []
444 | this.children = []
445 | this.replaceData(data, parentSyncKeys)
446 | this.parentModel = parentModel
447 | this.parentKey = parentKey
448 | }
449 | setOrdering(ordering: { first?: unknown; last?: unknown; orderBy?: unknown; direction?: unknown }) {
450 | let direction: 'asc' | 'desc' = 'asc'
451 | let orderBy: string = 'id'
452 | let first: number | undefined = undefined
453 | let last: number | undefined = undefined
454 | if (ordering.direction === 'desc') direction = ordering.direction
455 | if (typeof ordering.orderBy === 'string') orderBy = ordering.orderBy
456 | if (typeof ordering.first === 'number') first = ordering.first
457 | if (typeof ordering.last === 'number') last = ordering.last
458 | const subQuery = this.queryAttributes[orderBy]
459 | this.aliasOrderKey = (subQuery && subQuery.as) || orderBy
460 | this.ordering = { first, last, direction, orderBy }
461 | }
462 | setSyncKeys(parentSyncKeys: string[] | undefined) {
463 | if (parentSyncKeys) {
464 | this.syncKeys = parentSyncKeys.map(key => key + this.path)
465 | } else {
466 | this.syncKeys = []
467 | }
468 | }
469 | replaceData(data: any[] | { collection: any[]; ordering: Ordering }, parentSyncKeys: string[]) {
470 | this.setSyncKeys(parentSyncKeys)
471 | const existings = new Map()
472 | for (const child of this.children) existings.set(child.id, child)
473 | let collection: any[]
474 | if (Array.isArray(data)) {
475 | collection = data
476 | } else {
477 | collection = data.collection
478 | this.setOrdering(data.ordering)
479 | }
480 | const newChildren: any[] = []
481 | const newData: any[] = []
482 | for (const subData of collection) {
483 | let model: ArSyncRecord | undefined = undefined
484 | if (typeof(subData) === 'object' && subData && '_sync' in subData) model = existings.get(subData._sync.id)
485 | let data = subData
486 | if (model) {
487 | model.replaceData(subData)
488 | } else if (subData._sync) {
489 | model = new ArSyncRecord(this.query, subData, null, this.root, this, subData._sync.id)
490 | }
491 | if (model) {
492 | newChildren.push(model)
493 | data = model.data
494 | }
495 | newData.push(data)
496 | }
497 | while (this.children.length) {
498 | const child = this.children.pop()!
499 | if (!existings.has(child.id)) child.release()
500 | }
501 | if (this.data.length || newChildren.length) this.mark()
502 | while (this.data.length) this.data.pop()
503 | for (const child of newChildren) this.children.push(child)
504 | for (const el of newData) this.data.push(el)
505 | this.subscribeAll()
506 | }
507 | consumeAdd(className: string, id: IDType) {
508 | const { first, last, direction } = this.ordering
509 | const limit = first || last
510 | if (this.children.find(a => a.id === id)) return
511 | if (limit && limit <= this.children.length) {
512 | const lastItem = this.children[this.children.length - 1]
513 | const firstItem = this.children[0]
514 | if (direction === 'asc') {
515 | if (first) {
516 | if (lastItem && lastItem.id < id) return
517 | } else {
518 | if (firstItem && id < firstItem.id) return
519 | }
520 | } else {
521 | if (first) {
522 | if (lastItem && id < lastItem.id) return
523 | } else {
524 | if (firstItem && firstItem.id < id) return
525 | }
526 | }
527 | }
528 | this.fetching.add(id)
529 | modelBatchRequest.fetch(className, this.compactQueryAttributes, id).then((data: any) => {
530 | // Record already removed
531 | if (!this.fetching.has(id)) return
532 |
533 | this.fetching.delete(id)
534 | if (!data || !this.data) return
535 | const model = new ArSyncRecord(this.query, data, null, this.root, this, id)
536 | const overflow = limit && limit <= this.data.length
537 | let rmodel: ArSyncRecord | undefined
538 | this.mark()
539 | const orderKey = this.aliasOrderKey
540 | const firstItem = this.data[0]
541 | const lastItem = this.data[this.data.length - 1]
542 | if (direction === 'asc') {
543 | if (firstItem && data[orderKey] < firstItem[orderKey]) {
544 | this.children.unshift(model)
545 | this.data.unshift(model.data)
546 | } else {
547 | const skipSort = lastItem && lastItem[orderKey] < data[orderKey]
548 | this.children.push(model)
549 | this.data.push(model.data)
550 | if (!skipSort) this.markAndSort()
551 | }
552 | } else {
553 | if (firstItem && data[orderKey] > firstItem[orderKey]) {
554 | this.children.unshift(model)
555 | this.data.unshift(model.data)
556 | } else {
557 | const skipSort = lastItem && lastItem[orderKey] > data[orderKey]
558 | this.children.push(model)
559 | this.data.push(model.data)
560 | if (!skipSort) this.markAndSort()
561 | }
562 | }
563 | if (overflow) {
564 | if (first) {
565 | rmodel = this.children.pop()!
566 | this.data.pop()
567 | } else {
568 | rmodel = this.children.shift()!
569 | this.data.shift()
570 | }
571 | rmodel.release()
572 | }
573 | this.onChange([model.id], model.data)
574 | if (rmodel) this.onChange([rmodel.id], null)
575 | }).catch(e => {
576 | console.error(`failed to load ${className}:${id} ${e}`)
577 | })
578 | }
579 | markAndSort() {
580 | this.mark()
581 | const orderKey = this.aliasOrderKey
582 | if (this.ordering.direction === 'asc') {
583 | this.children.sort((a, b) => a.data[orderKey] < b.data[orderKey] ? -1 : +1)
584 | this.data.sort((a, b) => a[orderKey] < b[orderKey] ? -1 : +1)
585 | } else {
586 | this.children.sort((a, b) => a.data[orderKey] > b.data[orderKey] ? -1 : +1)
587 | this.data.sort((a, b) => a[orderKey] > b[orderKey] ? -1 : +1)
588 | }
589 | }
590 | consumeRemove(id: IDType) {
591 | const idx = this.children.findIndex(a => a.id === id)
592 | this.fetching.delete(id) // To cancel consumeAdd
593 | if (idx < 0) return
594 | this.mark()
595 | this.children[idx].release()
596 | this.children.splice(idx, 1)
597 | this.data.splice(idx, 1)
598 | this.onChange([id], null)
599 | }
600 | onNotify(notifyData: NotifyData) {
601 | if (notifyData.action === 'add') {
602 | this.consumeAdd(notifyData.class, notifyData.id)
603 | } else if (notifyData.action === 'remove') {
604 | this.consumeRemove(notifyData.id)
605 | }
606 | }
607 | subscribeAll() {
608 | const callback = data => this.onNotify(data)
609 | for (const key of this.syncKeys) this.subscribe(key, callback)
610 | }
611 | onChange(path: (string | number)[], data) {
612 | super.onChange(path, data)
613 | if (path[1] === this.aliasOrderKey) this.markAndSort()
614 | }
615 | markAndSet(id: IDType, data) {
616 | this.mark()
617 | const idx = this.children.findIndex(a => a.id === id)
618 | if (idx >= 0) this.data[idx] = data
619 | }
620 | mark() {
621 | if (!this.root || !this.root.immutable || !Object.isFrozen(this.data)) return
622 | this.data = [...this.data]
623 | this.root.mark(this.data)
624 | if (this.parentModel && this.parentKey) (this.parentModel as { markAndSet(key: string | number, data: any): any }).markAndSet(this.parentKey, this.data)
625 | }
626 | }
627 |
628 | export class ArSyncStore {
629 | immutable: boolean
630 | markedForFreezeObjects: any[]
631 | changes
632 | eventListeners
633 | markForRelease: true | undefined
634 | container
635 | request: Request
636 | complete = false
637 | notfound?: boolean
638 | destroyed = false
639 | data
640 | changesBufferTimer: number | undefined | null
641 | retryLoadTimer: number | undefined | null
642 | static connectionManager
643 | constructor(request: Request, { immutable } = {} as { immutable?: boolean }) {
644 | this.immutable = !!immutable
645 | this.markedForFreezeObjects = []
646 | this.changes = []
647 | this.eventListeners = { events: {}, serial: 0 }
648 | this.request = request
649 | this.data = null
650 | this.load(0)
651 | }
652 | handleDestroy() {
653 | this.release()
654 | this.data = null
655 | this.destroyed = true
656 | this.trigger('destroy')
657 | }
658 | load(retryCount: number) {
659 | ArSyncContainerBase.load(this.request, this).then((container: ArSyncContainerBase) => {
660 | if (this.markForRelease) {
661 | container.release()
662 | return
663 | }
664 | this.container = container
665 | this.data = container.data
666 | if (this.immutable) this.freezeRecursive(this.data)
667 | this.complete = true
668 | this.notfound = false
669 | this.trigger('load')
670 | this.trigger('change', { path: [], value: this.data })
671 | container.onChange = (path, value) => {
672 | this.changes.push({ path, value })
673 | this.setChangesBufferTimer()
674 | }
675 | container.onConnectionChange = state => {
676 | this.trigger('connection', state)
677 | }
678 | }).catch(e => {
679 | if (!e || e.retry === undefined) throw e
680 | if (this.markForRelease) return
681 | if (!e.retry) {
682 | this.complete = true
683 | this.notfound = true
684 | this.trigger('load')
685 | return
686 | }
687 | const sleepSeconds = Math.min(Math.pow(2, retryCount), 30)
688 | this.retryLoadTimer = setTimeout(
689 | () => {
690 | this.retryLoadTimer = null
691 | this.load(retryCount + 1)
692 | },
693 | sleepSeconds * 1000
694 | )
695 | })
696 | }
697 | setChangesBufferTimer() {
698 | if (this.changesBufferTimer) return
699 | this.changesBufferTimer = setTimeout(() => {
700 | this.changesBufferTimer = null
701 | const changes = this.changes
702 | this.changes = []
703 | this.freezeMarked()
704 | this.data = this.container.data
705 | changes.forEach(patch => this.trigger('change', patch))
706 | }, 20)
707 | }
708 | subscribe(event, callback) {
709 | let listeners = this.eventListeners.events[event]
710 | if (!listeners) this.eventListeners.events[event] = listeners = {}
711 | const id = this.eventListeners.serial++
712 | listeners[id] = callback
713 | return { unsubscribe: () => { delete listeners[id] } }
714 | }
715 | trigger(event, arg?) {
716 | const listeners = this.eventListeners.events[event]
717 | if (!listeners) return
718 | for (const id in listeners) listeners[id](arg)
719 | }
720 | mark(object) {
721 | this.markedForFreezeObjects.push(object)
722 | }
723 | freezeRecursive(obj) {
724 | if (Object.isFrozen(obj)) return obj
725 | for (const key in obj) this.freezeRecursive(obj[key])
726 | Object.freeze(obj)
727 | }
728 | freezeMarked() {
729 | this.markedForFreezeObjects.forEach(obj => this.freezeRecursive(obj))
730 | this.markedForFreezeObjects = []
731 | }
732 | release() {
733 | if (this.retryLoadTimer) clearTimeout(this.retryLoadTimer)
734 | if (this.changesBufferTimer) clearTimeout(this.changesBufferTimer)
735 | if (this.container) {
736 | this.container.release()
737 | } else {
738 | this.markForRelease = true
739 | }
740 | }
741 | }
742 |
--------------------------------------------------------------------------------
/src/core/ConnectionAdapter.ts:
--------------------------------------------------------------------------------
1 | export default interface ConnectionAdapter {
2 | ondisconnect: (() => void) | null
3 | onreconnect: (() => void) | null
4 | subscribe(key: string, callback: (data: any) => void): { unsubscribe: () => void }
5 | }
6 |
--------------------------------------------------------------------------------
/src/core/ConnectionManager.ts:
--------------------------------------------------------------------------------
1 | export default class ConnectionManager {
2 | subscriptions
3 | adapter
4 | networkListeners
5 | networkListenerSerial
6 | networkStatus
7 | constructor(adapter) {
8 | this.subscriptions = {}
9 | this.adapter = adapter
10 | this.networkListeners = {}
11 | this.networkListenerSerial = 0
12 | this.networkStatus = true
13 | adapter.ondisconnect = () => {
14 | this.unsubscribeAll()
15 | this.triggerNetworkChange(false)
16 | }
17 | adapter.onreconnect = () => this.triggerNetworkChange(true)
18 | }
19 | triggerNetworkChange(status) {
20 | if (this.networkStatus == status) return
21 | this.networkStatus = status
22 | for (const id in this.networkListeners) this.networkListeners[id](status)
23 | }
24 | unsubscribeAll() {
25 | for (const id in this.subscriptions) {
26 | const subscription = this.subscriptions[id]
27 | subscription.listeners = {}
28 | subscription.connection.unsubscribe()
29 | }
30 | this.subscriptions = {}
31 | }
32 | subscribeNetwork(func) {
33 | const id = this.networkListenerSerial++
34 | this.networkListeners[id] = func
35 | const unsubscribe = () => {
36 | delete this.networkListeners[id]
37 | }
38 | return { unsubscribe }
39 | }
40 | subscribe(key, func) {
41 | if (!this.networkStatus) return { unsubscribe(){} }
42 | const subscription = this.connect(key)
43 | const id = subscription.serial++
44 | subscription.ref++
45 | subscription.listeners[id] = func
46 | const unsubscribe = () => {
47 | if (!subscription.listeners[id]) return
48 | delete subscription.listeners[id]
49 | subscription.ref--
50 | if (subscription.ref === 0) this.disconnect(key)
51 | }
52 | return { unsubscribe }
53 | }
54 | connect(key) {
55 | if (this.subscriptions[key]) return this.subscriptions[key]
56 | const connection = this.adapter.subscribe(key, data => this.received(key, data))
57 | return this.subscriptions[key] = { connection, listeners: {}, ref: 0, serial: 0 }
58 | }
59 | disconnect(key) {
60 | const subscription = this.subscriptions[key]
61 | if (!subscription || subscription.ref !== 0) return
62 | delete this.subscriptions[key]
63 | subscription.connection.unsubscribe()
64 | }
65 | received(key, data) {
66 | const subscription = this.subscriptions[key]
67 | if (!subscription) return
68 | for (const id in subscription.listeners) subscription.listeners[id](data)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/core/DataType.ts:
--------------------------------------------------------------------------------
1 | type RecordType = { _meta?: { query: any } }
2 | type Values = T[keyof T]
3 | type AddNullable = null extends Test ? Type | null : Type
4 | type DataTypeExtractField = Exclude extends RecordType
5 | ? AddNullable
6 | : BaseType[Key] extends RecordType[]
7 | ? {}[]
8 | : BaseType[Key]
9 |
10 | type DataTypeExtractFieldsFromQuery = '*' extends Fields
11 | ? { [key in Exclude]: DataTypeExtractField }
12 | : { [key in Fields & keyof (BaseType)]: DataTypeExtractField }
13 |
14 | interface ExtraFieldErrorType {
15 | error: 'extraFieldError';
16 | }
17 |
18 | type DataTypeExtractFromQueryHash = '*' extends keyof QueryType
19 | ? {
20 | [key in Exclude<(keyof BaseType) | (keyof QueryType), '_meta' | '_params' | '*'>]: (key extends keyof BaseType
21 | ? (key extends keyof QueryType
22 | ? (QueryType[key] extends true
23 | ? DataTypeExtractField
24 | : AddNullable>)
25 | : DataTypeExtractField)
26 | : ExtraFieldErrorType)
27 | }
28 | : {
29 | [key in keyof QueryType]: (key extends keyof BaseType
30 | ? (QueryType[key] extends true
31 | ? DataTypeExtractField
32 | : AddNullable>)
33 | : ExtraFieldErrorType)
34 | }
35 |
36 | type _DataTypeFromQuery = QueryType extends keyof BaseType | '*'
37 | ? DataTypeExtractFieldsFromQuery
38 | : QueryType extends Readonly<(keyof BaseType | '*')[]>
39 | ? DataTypeExtractFieldsFromQuery>
40 | : QueryType extends { as: string }
41 | ? { error: 'type for alias field is not supported' } | undefined
42 | : DataTypeExtractFromQueryHash
43 |
44 | export type DataTypeFromQuery = BaseType extends any[]
45 | ? CheckAttributesField[]
46 | : AddNullable>
47 |
48 | type CheckAttributesField = Q extends { attributes: infer R }
49 | ? _DataTypeFromQuery
50 | : _DataTypeFromQuery
51 |
52 | type IsAnyCompareLeftType = { __any: never }
53 |
54 | type CollectExtraFields =
55 | IsAnyCompareLeftType extends Type
56 | ? never
57 | : Type extends ExtraFieldErrorType
58 | ? Key
59 | : Type extends (infer R)[]
60 | ? {
61 | 0: Values<{ [key in keyof R]: CollectExtraFields }>
62 | 1: never
63 | }[R extends object ? 0 : 1]
64 | : {
65 | 0: Values<{ [key in keyof Type]: CollectExtraFields }>
66 | 1: never
67 | }[Type extends object ? 0 : 1]
68 |
69 | type SelectString = T extends string ? T : never
70 | type _ValidateDataTypeExtraFileds = SelectString extends never
71 | ? Type
72 | : { error: { extraFields: Extra } }
73 | type ValidateDataTypeExtraFileds = _ValidateDataTypeExtraFileds, Type>
74 |
75 | type RequestBase = { api: string; query: any; id?: number; params?: any; _meta?: { data: any } }
76 | type DataTypeBaseFromRequestType = R extends { _meta?: { data: infer DataType } }
77 | ? (
78 | ID extends number
79 | ? ([DataType, R['params']] extends [(infer DT)[], { ids: number[] } | undefined] ? DT : never)
80 | : DataType
81 | )
82 | : never
83 | export type DataTypeFromRequest = ValidateDataTypeExtraFileds<
84 | DataTypeFromQuery, R['query']>
85 | >
86 |
--------------------------------------------------------------------------------
/src/core/hooks.ts:
--------------------------------------------------------------------------------
1 | import ArSyncAPI from './ArSyncApi'
2 | import ArSyncModel from './ArSyncModel'
3 |
4 | let useState: (t: T | (() => T)) => [T, (t: T | ((t: T) => T)) => void]
5 | let useEffect: (f: (() => void) | (() => (() => void)), deps: any[]) => void
6 | let useMemo: (f: () => T, deps: any[]) => T
7 | let useRef: (value: T) => { current: T }
8 | type InitializeHooksParams = {
9 | useState: typeof useState
10 | useEffect: typeof useEffect
11 | useMemo: typeof useMemo
12 | useRef: typeof useRef
13 | }
14 | export function initializeHooks(hooks: InitializeHooksParams) {
15 | useState = hooks.useState
16 | useEffect = hooks.useEffect
17 | useMemo = hooks.useMemo
18 | useRef = hooks.useRef
19 | }
20 | function checkHooks() {
21 | if (!useState) throw 'uninitialized. needs `initializeHooks({ useState, useEffect, useMemo, useRef })`'
22 | }
23 |
24 | interface ModelStatus { complete: boolean; notfound?: boolean; connected: boolean; destroyed: boolean }
25 | export type DataAndStatus = [T | null, ModelStatus]
26 | export interface Request { api: string; params?: any; id?: number; query: any }
27 |
28 | const initialResult: DataAndStatus = [null, { complete: false, notfound: undefined, connected: true, destroyed: false }]
29 | export function useArSyncModel(request: Request | null): DataAndStatus {
30 | checkHooks()
31 | const [result, setResult] = useState>(initialResult)
32 | const requestString = JSON.stringify(request?.id ?? request?.params)
33 | const prevRequestStringRef = useRef(requestString)
34 | useEffect(() => {
35 | prevRequestStringRef.current = requestString
36 | if (!request) {
37 | setResult(initialResult)
38 | return () => {}
39 | }
40 | const model = new ArSyncModel(request, { immutable: true })
41 | function update() {
42 | const { complete, notfound, connected, destroyed, data } = model
43 | setResult(resultWas => {
44 | const [dataWas, statusWas] = resultWas
45 | const statusPersisted = statusWas.complete === complete && statusWas.notfound === notfound && statusWas.connected === connected && statusWas.destroyed === destroyed
46 | if (dataWas === data && statusPersisted) return resultWas
47 | const status = statusPersisted ? statusWas : { complete, notfound, connected, destroyed }
48 | return [data, status]
49 | })
50 | }
51 | if (model.complete) {
52 | update()
53 | } else {
54 | setResult(initialResult)
55 | }
56 | model.subscribe('load', update)
57 | model.subscribe('change', update)
58 | model.subscribe('destroy', update)
59 | model.subscribe('connection', update)
60 | return () => model.release()
61 | }, [requestString])
62 | return prevRequestStringRef.current === requestString ? result : initialResult
63 | }
64 |
65 | interface FetchStatus { complete: boolean; notfound?: boolean }
66 | type DataStatusUpdate = [T | null, FetchStatus, () => void]
67 | type FetchState = { data: T | null; status: FetchStatus }
68 | const initialFetchState: FetchState = { data: null, status: { complete: false, notfound: undefined } }
69 |
70 | function extractParams(query: unknown, output: any[] = []): any[] {
71 | if (typeof(query) !== 'object' || query == null || Array.isArray(query)) return output
72 | if ('params' in query) output.push((query as { params: any }).params)
73 | for (const key in query) {
74 | extractParams(query[key], output)
75 | }
76 | return output
77 | }
78 |
79 | export function useArSyncFetch(request: Request | null): DataStatusUpdate {
80 | checkHooks()
81 | const [state, setState] = useState>(initialFetchState)
82 | const query = request && request.query
83 | const resourceIdentifier = request?.id ?? request?.params
84 | const requestString = useMemo(() => {
85 | return JSON.stringify(extractParams(query, [resourceIdentifier]))
86 | }, [query, resourceIdentifier])
87 | const prevRequestStringRef = useRef(requestString)
88 | const loader = useMemo(() => {
89 | let lastLoadId = 0
90 | let timer: null | number = null
91 | function cancel() {
92 | if (timer) clearTimeout(timer)
93 | timer = null
94 | lastLoadId++
95 | }
96 | function fetch(request: Request, retryCount: number) {
97 | cancel()
98 | const currentLoadingId = lastLoadId
99 | ArSyncAPI.fetch(request).then((response: T) => {
100 | if (currentLoadingId !== lastLoadId) return
101 | setState({ data: response, status: { complete: true, notfound: false } })
102 | }).catch(e => {
103 | if (currentLoadingId !== lastLoadId) return
104 | if (!e.retry) {
105 | setState({ data: null, status: { complete: true, notfound: true } })
106 | return
107 | }
108 | timer = setTimeout(() => fetch(request, retryCount + 1), 1000 * Math.min(4 ** retryCount, 30))
109 | })
110 | }
111 | function update() {
112 | if (request) {
113 | setState(state => {
114 | const { data, status } = state
115 | if (!status.complete && status.notfound === undefined) return state
116 | return { data, status: { complete: false, notfound: undefined } }
117 | })
118 | fetch(request, 0)
119 | } else {
120 | setState(initialFetchState)
121 | }
122 | }
123 | return { update, cancel }
124 | }, [requestString])
125 | useEffect(() => {
126 | prevRequestStringRef.current = requestString
127 | setState(initialFetchState)
128 | loader.update()
129 | return () => loader.cancel()
130 | }, [requestString])
131 | const responseState = prevRequestStringRef.current === requestString ? state : initialFetchState
132 | return [responseState.data, responseState.status, loader.update]
133 | }
134 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ArSyncModel } from './core/ArSyncModel'
2 | export { default as ArSyncApi } from './core/ArSyncApi'
3 |
--------------------------------------------------------------------------------
/test/db.rb:
--------------------------------------------------------------------------------
1 | require 'benchmark'
2 | require 'active_record'
3 | config = {
4 | adapter: 'sqlite3',
5 | database: ENV['DATABASE_NAME'] || 'test/development.sqlite3',
6 | pool: 5,
7 | timeout: 5000
8 | }
9 | ActiveRecord::Base.establish_connection config
10 | ActiveRecord::Base.logger = Logger.new(STDOUT)
11 |
--------------------------------------------------------------------------------
/test/helper/connection_adapter.js:
--------------------------------------------------------------------------------
1 | class ConnectionAdapter {
2 | constructor() {
3 | this.rooms = {}
4 | }
5 | subscribe(key, callback) {
6 | let room = this.rooms[key]
7 | if (!room) room = this.rooms[key] = { id: 0, callbacks: {}, counts: 0 }
8 | const subKey = room.id++
9 | room.callbacks[subKey] = callback
10 | room.count++
11 | const unsubscribe = () => {
12 | delete room.callbacks[subKey]
13 | room.count--
14 | if (room.count === 0) delete this.rooms[key]
15 | }
16 | return { unsubscribe }
17 | }
18 | notify(key, data) {
19 | const room = this.rooms[key]
20 | if (!room) return
21 | for (const cb of Object.values(room.callbacks)) cb(data)
22 | }
23 | }
24 |
25 | module.exports = ConnectionAdapter
26 |
--------------------------------------------------------------------------------
/test/helper/test_runner.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const readline = require('readline')
3 | const input = readline.createInterface({ input: process.stdin })
4 | const ConnectionAdapter = new require('./connection_adapter')
5 | const ArSyncModel = require('../../core/ArSyncModel').default
6 | const ArSyncApi = require('../../core/ArSyncApi').default
7 | const connectionAdapter = new ConnectionAdapter
8 | ArSyncModel.setConnectionAdapter(connectionAdapter)
9 |
10 | const waitingCallbacks = {}
11 | ArSyncApi._batchFetch = (_, requests) => {
12 | const key = Math.random()
13 | const data = { __test_runner_type: 'request', key: key, data: requests }
14 | process.stdout.write(JSON.stringify(data) + '\n')
15 | return new Promise(res => {
16 | waitingCallbacks[key] = (data) => {
17 | delete waitingCallbacks[key]
18 | res(data)
19 | }
20 | })
21 | }
22 |
23 | input.on('line', line => {
24 | const e = JSON.parse(line)
25 | switch (e.type) {
26 | case 'eval': {
27 | let result, error, responseJSON
28 | try {
29 | result = eval(e.data)
30 | try {
31 | if (result && !JSON.stringify(result)) throw ''
32 | } catch (e) {
33 | result = `[${result.constructor.name}]`
34 | }
35 | } catch (e) {
36 | error = e.message
37 | }
38 | const data = {
39 | __test_runner_type: 'result',
40 | key: e.key,
41 | result,
42 | error
43 | }
44 | process.stdout.write(JSON.stringify(data) + '\n')
45 | break
46 | }
47 | case 'response': {
48 | const cb = waitingCallbacks[e.key]
49 | if (cb) cb(e.data)
50 | break
51 | }
52 | case 'notify': {
53 | connectionAdapter.notify(e.key, e.data)
54 | break
55 | }
56 | }
57 | })
58 |
--------------------------------------------------------------------------------
/test/helper/test_runner.rb:
--------------------------------------------------------------------------------
1 | require 'json'
2 |
3 | class TestRunner
4 | def initialize(schema, current_user)
5 | Dir.chdir File.dirname(__FILE__) do
6 | @io = IO.popen 'node test_runner.js', 'w+'
7 | end
8 | @eval_responses = {}
9 | @schema = schema
10 | @current_user = current_user
11 | configure
12 | start
13 | end
14 |
15 | def configure
16 | ArSync.on_notification do |events|
17 | events.each do |key, patch|
18 | notify key, patch
19 | end
20 | end
21 | end
22 |
23 | def handle_requests(requests)
24 | requests.map do |request|
25 | user = @current_user.reload
26 | query = {
27 | request['api'] => {
28 | as: :data,
29 | params: request['params'],
30 | attributes: request['query']
31 | }
32 | }
33 | begin
34 | ArSync.sync_serialize @schema, user, query
35 | rescue ActiveRecord::RecordNotFound => e
36 | { error: { type: 'Record Not Found', message: e.message } }
37 | end
38 | end
39 | end
40 |
41 | def start
42 | Thread.new { _run }
43 | end
44 |
45 | def _run
46 | while data = @io.gets
47 | begin
48 | e = JSON.parse data rescue nil
49 | type = e.is_a?(Hash) && e['__test_runner_type']
50 | case type
51 | when 'result'
52 | @eval_responses[e['key']] << e
53 | when 'request'
54 | response = handle_requests e['data']
55 | @io.puts({ type: :response, key: e['key'], data: response }.to_json)
56 | else
57 | puts data
58 | end
59 | rescue => e
60 | puts e
61 | puts e.backtrace
62 | end
63 | end
64 | end
65 |
66 | def eval_script(code)
67 | key = rand
68 | queue = @eval_responses[key] = Queue.new
69 | @io.puts({ type: :eval, key: key, data: code }.to_json)
70 | output = queue.deq
71 | @eval_responses.delete key
72 | raise output['error'] if output['error']
73 | output['result']
74 | end
75 |
76 | def assert_script(code, **options, &block)
77 | timeout = options.has_key?(:timeout) ? options.delete(:timeout) : 1
78 | start = Time.now
79 |
80 | if options.empty?
81 | block ||= :itself.to_proc
82 | else
83 | block = lambda do |v|
84 | options.all? do |key, value|
85 | /^(?not_)?to_(?.+)/ =~ key
86 | result = verb == 'be' ? v == value : v.send(verb + '?', value)
87 | reversed ? !result : result
88 | end
89 | end
90 | end
91 | return true if block.call eval_script(code)
92 | loop do
93 | sleep [timeout, 0.05].min
94 | result = eval_script code
95 | return true if block.call result
96 | raise "Assert failed: #{result.inspect} #{options unless options.empty?}" if Time.now - start > timeout
97 | end
98 | end
99 |
100 | def notify(key, data)
101 | @io.puts({ type: :notify, key: key, data: data }.to_json)
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/test/model.rb:
--------------------------------------------------------------------------------
1 | require_relative 'db'
2 | require 'ar_sync'
3 |
4 | class BaseRecord < ActiveRecord::Base
5 | include ArSync::ModelBase
6 | self.abstract_class = true
7 | end
8 |
9 | class User < BaseRecord
10 | self.table_name = :users
11 | has_many :posts, dependent: :destroy
12 | sync_has_data :id, :name
13 | sync_has_many :posts
14 | sync_has_one(:postOrNull, type: ->{ [Post, nil] }) { nil }
15 | sync_has_data(:itemWithId) { { id: 1, value: 'data' } }
16 | sync_has_data(:itemsWithId) { [{ id: 1, value: 'data' }] }
17 | end
18 |
19 | class Post < BaseRecord
20 | before_validation do
21 | self.id ||= '%08d' % (Post.maximum(:id).to_i + 1)
22 | end
23 | self.table_name = :posts
24 | belongs_to :user
25 | has_many :comments, dependent: :destroy
26 |
27 | sync_define_collection :all
28 | sync_define_collection :first10, first: 10
29 | sync_define_collection :last10, last: 10
30 |
31 | sync_parent :user, inverse_of: :posts
32 | sync_has_data :id, :title, :body
33 | sync_has_data(:titleChars) { (title || '').chars }
34 | sync_has_one :user, only: [:id, :name]
35 | sync_has_many :comments
36 | sync_has_many :myComments, preload: lambda { |posts, user|
37 | Comment.where(post_id: posts.map(&:id), user: user).group_by(&:post_id)
38 | } do |preloaded|
39 | preloaded[id] || []
40 | end
41 | end
42 |
43 | class Comment < BaseRecord
44 | self.table_name = :comments
45 | belongs_to :user
46 | belongs_to :post
47 | has_many :stars, dependent: :destroy
48 | sync_parent :post, inverse_of: :comments
49 | sync_parent :post, inverse_of: :myComments, only_to: :user
50 | sync_has_data :id, :body
51 | sync_has_one :user, only: [:id, :name]
52 | sync_has_data(:starCount, preload: lambda { |comments|
53 | Star.where(comment_id: comments.map(&:id)).group(:comment_id).count
54 | }) { |preload| preload[id] || 0 }
55 |
56 | my_stars_loader = ->(comments, user) do
57 | Star.where(user: user, comment_id: comments.map(&:id)).group_by(&:comment_id)
58 | end
59 |
60 | sync_has_one :myStar, preload: my_stars_loader do |preloaded|
61 | preloaded[id]&.first
62 | end
63 | sync_has_many :myStars, preload: my_stars_loader do |preloaded|
64 | preloaded[id] || []
65 | end
66 |
67 | sync_has_data :editedStarCount, preload: ->(comments) do
68 | counts = Star.where('created_at != updated_at').where(comment_id: comments.map(&:id)).group(:comment_id).count
69 | Hash.new(0).merge counts
70 | end
71 | end
72 |
73 | class Star < BaseRecord
74 | self.table_name = :stars
75 | belongs_to :user
76 | belongs_to :comment
77 | sync_has_data :id, :created_at, :type
78 | sync_has_one :user, only: [:id, :name]
79 | sync_parent :comment, inverse_of: :starCount
80 | sync_parent :comment, inverse_of: :myStar, only_to: -> { user }
81 | sync_parent :comment, inverse_of: :myStars, only_to: -> { user }
82 | sync_parent :comment, inverse_of: :editedStarCount, watch: :updated_at
83 | end
84 |
85 | class YellowStar < Star; end
86 | class RedStar < Star; end
87 | class GreenStar < Star; end
88 |
--------------------------------------------------------------------------------
/test/request_test.rb:
--------------------------------------------------------------------------------
1 | require 'minitest/autorun'
2 | require 'ar_sync'
3 | module Rails; class Engine; end; end
4 | module ActionController
5 | class Base
6 | def self.protect_from_forgery(*); end
7 | def self.around_action(*); end
8 | end
9 | end
10 | require 'ar_sync/rails'
11 |
12 | class TsTest < Minitest::Test
13 | PARAMS_TYPE = { fooId: :int, bar: { bazId: :int } }
14 | class Model
15 | def self.after_initialize(*, &); end
16 | def self.before_destroy(*, &); end
17 | def self.after_commit(*, &); end
18 | include ArSerializer::Serializable
19 | include ArSync::ModelBase
20 | def self.validate!(foo_id, bar)
21 | raise unless foo_id.is_a?(Integer) && bar[:baz_id].is_a?(Integer)
22 | end
23 | serializer_field :model1, type: Model, params_type: PARAMS_TYPE do |_user, foo_id:, bar:|
24 | Model.validate! foo_id, bar
25 | Model.new
26 | end
27 | sync_has_one :model2, type: Model, params_type: PARAMS_TYPE do |_user, foo_id:, bar:|
28 | Model.validate! foo_id, bar
29 | Model.new
30 | end
31 | def id; 1; end
32 | end
33 |
34 | class Schema
35 | include ArSerializer::Serializable
36 | serializer_field :model, type: Model, params_type: PARAMS_TYPE do |_user, foo_id:, bar:|
37 | Model.validate! foo_id, bar
38 | Model.new
39 | end
40 | serializer_field :hash do
41 | { x: 0, y: 0 }
42 | end
43 | end
44 |
45 | class Controller < ActionController::Base
46 | include ArSync::ApiControllerConcern
47 |
48 | attr_reader :params, :result, :schema, :current_user
49 |
50 | def initialize(request_params)
51 | @params = request_params
52 | @schema = Schema.new
53 | @current_user = nil
54 | end
55 |
56 | def log_internal_exception(e)
57 | raise e
58 | end
59 |
60 | def render(result)
61 | @result = result
62 | end
63 | end
64 |
65 | def test_rails_api_response
66 | params = { fooId: 1, bar: { bazId: 2 } }
67 | requests = [
68 | {
69 | api: :model,
70 | params: params,
71 | query: {
72 | id: true,
73 | model1: { params: params, attributes: :id },
74 | model2: { params: params, attributes: :id }
75 | }
76 | },
77 | { api: :hash, query: {} }
78 | ]
79 | expected = { json: [{ data: { id: 1, model1: { id: 1 }, model2: { id: 1 } } }, { data: { x: 0, y: 0 } }] }
80 | assert_equal expected, Controller.new({ requests: requests }).tap(&:static_call).result
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/test/seed.rb:
--------------------------------------------------------------------------------
1 | require 'activerecord-import'
2 | require_relative 'db'
3 | require_relative 'model'
4 | database_file = ActiveRecord::Base.connection.instance_eval { @config[:database] }
5 | File.unlink database_file if File.exist? database_file
6 | ActiveRecord::Base.connection_handler.clear_all_connections!
7 | ActiveRecord::Migration::Current.class_eval do
8 | create_table :users do |t|
9 | t.string :name
10 | end
11 | create_table :posts, id: :string do |t|
12 | t.references :user
13 | t.string :title
14 | t.text :body
15 | t.timestamps
16 | end
17 |
18 | create_table :comments do |t|
19 | t.references :post, type: :string
20 | t.references :user
21 | t.text :body
22 | t.timestamps
23 | end
24 |
25 | create_table :stars do |t|
26 | t.string :type
27 | t.references :comment
28 | t.references :user
29 | t.timestamps
30 | end
31 | end
32 |
33 | srand 0
34 | users = 4.times.map { |i| { name: "User#{i}" } }
35 | User.import users
36 | user_ids = User.ids
37 |
38 | posts = 16.times.map do |i|
39 | { id: '%08d' % (i + 1), user_id: user_ids.sample, title: "Post#{i}", body: "post #{i}" }
40 | end
41 | Post.import posts
42 | post_ids = Post.ids
43 |
44 | comments = 64.times.map do |i|
45 | { user_id: user_ids.sample, post_id: post_ids.sample, body: "comment #{i}" }
46 | end
47 | Comment.import comments
48 | comment_ids = Comment.ids
49 |
50 | sets = Set.new
51 | color_klasses = ['YellowStar', 'RedStar', 'GreenStar']
52 | stars = 128.times.map do
53 | type = color_klasses.sample
54 | user_id = user_ids.sample
55 | comment_id = comment_ids.sample
56 | while sets.include? [user_id, comment_id]
57 | user_id = user_ids.sample
58 | comment_id = comment_ids.sample
59 | end
60 | sets.add [user_id, comment_id]
61 | { type: type, user_id: user_id, comment_id: comment_id }
62 | end
63 | Star.import stars
64 |
65 | p users.size, posts.size, comments.size, stars.size
66 |
--------------------------------------------------------------------------------
/test/sync_test.rb:
--------------------------------------------------------------------------------
1 | database_name = 'test/test.sqlite3'
2 | ENV['DATABASE_NAME'] = database_name
3 | ['', '-journal', '-shm', '-wal'].each do |suffix|
4 | file = database_name + suffix
5 | File.unlink file if File.exist? file
6 | end
7 | require_relative 'seed'
8 | require_relative 'helper/test_runner'
9 | require_relative 'model'
10 |
11 | class Schema
12 | include ArSerializer::Serializable
13 | serializer_field(:currentUser) { |user| user }
14 | serializer_field(:post) { |_user, id:| Post.find id }
15 | serializer_field(:comment) { |_user, id:| Post.find id }
16 | serializer_field(:nil) { nil }
17 |
18 | [User, Post, Comment, Star].each do |klass|
19 | serializer_field(klass.name) { |_user, ids:| klass.where id: ids }
20 | end
21 | end
22 |
23 | user = User.second
24 | runner = TestRunner.new Schema.new, user
25 |
26 | # _sync_xxx_before_mutation can be nil
27 | post = Post.last
28 | post.update title: post.title + '!'
29 | post.save
30 |
31 | runner.eval_script <<~JAVASCRIPT
32 | global.userModel = new ArSyncModel({
33 | api: 'currentUser',
34 | query: {
35 | name: { as: '名前' },
36 | posts: {
37 | as: 'articles',
38 | attributes: {
39 | user: ['id', 'name'],
40 | title: true,
41 | myComments: { as: 'myCmnts', attributes: ['id', 'starCount'] },
42 | comments: {
43 | id: true,
44 | starCount: { as: '星' },
45 | user: ['id', 'name'],
46 | myStar: { as: 'myReaction', attributes: 'id' }
47 | }
48 | }
49 | }
50 | }
51 | })
52 | JAVASCRIPT
53 |
54 | users = User.all.to_a
55 |
56 | tap do # load test
57 | runner.assert_script 'userModel.data'
58 | runner.assert_script 'userModel.data.articles.length > 0'
59 | end
60 |
61 | tap do # field udpate test
62 | post = user.posts.first
63 | name = "Name#{rand}"
64 | user.update name: name
65 | runner.assert_script 'userModel.data.名前', to_be: name
66 | runner.assert_script 'userModel.data.articles[0].user.name', to_be: name
67 | title = "Title#{rand}"
68 | post.update title: title
69 | runner.assert_script 'userModel.data.articles[0].title', to_be: title
70 | end
71 |
72 | tap do # has_many update & destroy
73 | title = "NewPost#{rand}"
74 | new_post = user.posts.create user: users.sample, title: title
75 | runner.assert_script 'userModel.data.articles.map(a => a.title)', to_include: title
76 | new_comment = new_post.comments.create user: users.sample
77 | idx = user.posts.size - 1
78 | runner.assert_script "userModel.data.articles[#{idx}].comments.map(c => c.id)", to_include: new_comment.id
79 | new_comment.destroy
80 | runner.assert_script "userModel.data.articles[#{idx}].comments.map(c => c.id)", not_to_include: new_comment.id
81 | new_post.destroy
82 | runner.assert_script 'userModel.data.articles.map(a => a.title)', not_to_include: title
83 | end
84 |
85 | tap do # has_one change
86 | comment = user.posts.first.comments.first
87 | runner.assert_script 'userModel.data.articles[0].comments[0].user.name', to_be: comment.user.name
88 | comment.update user: nil
89 | runner.assert_script 'userModel.data.articles[0].comments[0].user', to_be: nil
90 | comment.update user: users.second
91 | runner.assert_script 'userModel.data.articles[0].comments[0].user'
92 | runner.assert_script 'userModel.data.articles[0].comments[0].user.id', to_be: users.second.id
93 | comment.update user: users.third
94 | runner.assert_script 'userModel.data.articles[0].comments[0].user.id', to_be: users.third.id
95 | end
96 |
97 | tap do # has_many destroy fast
98 | title1 = "NewPost#{rand}"
99 | title2 = "NewPost#{rand}"
100 | temp_post = user.posts.create user: users.sample, title: title1
101 | # Emulate record destroyed just after fetch started
102 | temp_post._initialize_sync_info_before_mutation
103 | temp_post._sync_notify :destroy
104 | new_post = user.posts.create user: users.sample, title: title2
105 | runner.assert_script 'userModel.data.articles.map(a => a.title)', to_include: title2, not_to_include: title1
106 | temp_post.destroy
107 | new_post.destroy
108 | end
109 |
110 | tap do # has_one destroy fast
111 | comment = user.posts.first.comments.first
112 | comment_code = 'userModel.data.articles[0].comments[0]'
113 | comment.stars.where(user: user).destroy_all
114 | runner.assert_script "#{comment_code}.myReaction", to_be: nil
115 | # Emulate record destroyed just after fetch started
116 | star = comment.stars.where(user: user).create
117 | star._initialize_sync_info_before_mutation
118 | star._sync_notify :destroy
119 | comment.user.update name: rand.to_s
120 | runner.assert_script "#{comment_code}.user.name", to_be: comment.user.name
121 | runner.assert_script "#{comment_code}.myReaction", to_be: nil
122 | star.destroy
123 | end
124 |
125 | tap do # parent replace
126 | comment = user.posts.first.comments.first
127 | runner.assert_script 'userModel.data.articles[0].comments[0].id', to_be: comment.id
128 | comment.update post: user.posts.second
129 | runner.assert_script 'userModel.data.articles[0].comments[0].id', not_to_be: comment.id
130 | runner.assert_script 'userModel.data.articles[1].comments.map(c => c.id)', to_include: comment.id
131 | end
132 |
133 | tap do # per-user has_many
134 | post = user.posts.first
135 | other_user = (users - [user]).sample
136 | comment_other = post.comments.create user: other_user, body: rand.to_s
137 | comment_self = post.comments.create user: user, body: rand.to_s
138 | all_comments_code = 'userModel.data.articles[0].comments.map(c => c.id)'
139 | my_comments_code = 'userModel.data.articles[0].myCmnts.map(c => c.id)'
140 | runner.assert_script all_comments_code, to_include: comment_other.id
141 | runner.assert_script all_comments_code, to_include: comment_self.id
142 | runner.assert_script my_comments_code, not_to_include: comment_other.id
143 | runner.assert_script my_comments_code, to_include: comment_self.id
144 | comment_other.update user: user
145 | runner.assert_script my_comments_code, to_include: comment_other.id
146 | runner.assert_script my_comments_code, to_include: comment_self.id
147 | comment_self.update user: other_user
148 | runner.assert_script my_comments_code, to_include: comment_other.id
149 | runner.assert_script my_comments_code, not_to_include: comment_self.id
150 | post.comments.reload
151 | end
152 |
153 | tap do # per-user has_one
154 | comment = user.posts.first.comments.first
155 | other_user = (users - [user]).sample
156 | comment_code = 'userModel.data.articles[0].comments[0]'
157 | comment.stars.where(user: user).first_or_create
158 | runner.assert_script "#{comment_code}.myReaction"
159 | comment.stars.find_by(user: user).destroy
160 | runner.assert_script "#{comment_code}.myReaction", to_be: nil
161 | comment.stars.find_by(user: other_user)&.destroy
162 | comment.stars.create(user: other_user)
163 | runner.assert_script "#{comment_code}.星", to_be: comment.stars.count
164 | runner.assert_script "#{comment_code}.myReaction", to_be: nil
165 | star = comment.stars.create(user: user)
166 | runner.assert_script "#{comment_code}.星", to_be: comment.stars.count
167 | runner.assert_script "#{comment_code}.myReaction"
168 | runner.assert_script "#{comment_code}.myReaction.id", to_be: star.id
169 | end
170 |
171 | tap do # order test
172 | post = Post.first
173 | 10.times do
174 | post.comments.create user: users.sample
175 | end
176 | [:asc, :desc].each do |direction|
177 | post.comments.each do |c|
178 | c.update body: rand.to_s
179 | end
180 | runner.eval_script <<-JAVASCRIPT
181 | global.postModel = new ArSyncModel({
182 | api: 'post',
183 | params: { id: #{post.id.to_json} },
184 | query: {
185 | comments: {
186 | params: { orderBy: 'body', direction: '#{direction}' },
187 | attributes: { id: true, body: { as: 'text' } }
188 | }
189 | }
190 | })
191 | JAVASCRIPT
192 | runner.assert_script 'postModel.data'
193 | comments_code = 'postModel.data.comments.map(c => c.id)'
194 | current_order = -> do
195 | sorted = post.comments.reload.sort_by(&:body).map(&:id)
196 | direction == :asc ? sorted : sorted.reverse
197 | end
198 | runner.assert_script comments_code, to_be: current_order.call
199 | post.comments.create user: users.sample, body: '0.6'
200 | runner.assert_script comments_code, to_be: current_order.call
201 | post.comments.sort_by(&:body).first.update body: '0.4'
202 | runner.assert_script comments_code, to_be: current_order.call
203 | comments_body_code = 'postModel.data.comments.map(c => c.text)'
204 | runner.assert_script comments_body_code, to_include: '0.4'
205 | runner.assert_script comments_body_code, to_include: '0.6'
206 | runner.eval_script 'postModel.release(); postModel = null'
207 | end
208 | end
209 |
210 | tap do # first last direction test
211 | post = Post.first
212 | [:asc, :desc].product([:first, :last]) do |direction, first_last|
213 | post.comments.destroy_all
214 | runner.eval_script <<-JAVASCRIPT
215 | global.postModel = new ArSyncModel({
216 | api: 'post',
217 | params: { id: #{post.id.to_json} },
218 | query: {
219 | comments: {
220 | params: { #{first_last}: 3, direction: '#{direction}' },
221 | attributes: { id: true }
222 | }
223 | }
224 | })
225 | JAVASCRIPT
226 | runner.assert_script 'postModel.data'
227 | current_comment_ids = -> do
228 | ids = post.comments.reload.order(id: :asc).map(&:id)
229 | ids.reverse! if direction == :desc
230 | ids.__send__(first_last, 3)
231 | end
232 | comment_ids_code = 'postModel.data.comments.map(c => c.id)'
233 | test = -> {
234 | runner.assert_script comment_ids_code, to_be: current_comment_ids.call
235 | }
236 | 5.times do
237 | post.comments.create
238 | test.call
239 | end
240 | if (direction == :asc) ^ (first_last == :first)
241 | 2.times do
242 | post.comments.first.destroy
243 | post.comments.last.destroy
244 | post.comments.create
245 | test.call
246 | end
247 | end
248 | runner.eval_script 'postModel.release(); postModel = null'
249 | end
250 | end
251 |
252 | tap do # no subquery test
253 | runner.eval_script <<~JAVASCRIPT
254 | global.noSubqueryTestModel = new ArSyncModel({
255 | api: 'currentUser',
256 | query: ['id', { posts: 'id' }]
257 | })
258 | JAVASCRIPT
259 | runner.assert_script 'noSubqueryTestModel.data'
260 | runner.assert_script 'noSubqueryTestModel.data.posts[0].id >= 1'
261 | end
262 |
263 | tap do # object field test
264 | runner.eval_script <<~JAVASCRIPT
265 | global.objectFieldTestModel = new ArSyncModel({
266 | api: 'currentUser',
267 | query: ['itemWithId', 'itemsWithId']
268 | })
269 | JAVASCRIPT
270 | runner.assert_script 'objectFieldTestModel.data'
271 | runner.assert_script 'objectFieldTestModel.data.itemWithId', to_be: { 'id' => 1, 'value' => 'data' }
272 | runner.assert_script 'objectFieldTestModel.data.itemsWithId', to_be: [{ 'id' => 1, 'value' => 'data' }]
273 | end
274 |
275 | tap do # wildcard update test
276 | runner.eval_script <<~JAVASCRIPT
277 | global.wildCardTestModel = new ArSyncModel({
278 | api: 'currentUser',
279 | query: { posts: '*' }
280 | })
281 | JAVASCRIPT
282 | runner.assert_script 'wildCardTestModel.data'
283 | title = "Title#{rand}"
284 | user.posts.reload.second.update title: title
285 | runner.assert_script 'wildCardTestModel.data.posts[1].title', to_be: title
286 | runner.eval_script 'wildCardTestModel.release(); wildCardTestModel = null'
287 | end
288 |
289 | tap do # plain-array filed test
290 | post1 = Post.first
291 | post2 = Post.second
292 | post1.update title: ''
293 | post2.update title: 'abc'
294 | [post1, post2].each do |post|
295 | runner.eval_script <<~JAVASCRIPT
296 | global.postModel = new ArSyncModel({
297 | api: 'post',
298 | params: { id: #{post.id.to_json} },
299 | query: ['id','titleChars']
300 | })
301 | JAVASCRIPT
302 | runner.assert_script 'postModel.data'
303 | runner.assert_script 'postModel.data.titleChars', to_be: post.title.chars
304 | chars = rand.to_s.chars
305 | post.update title: chars.join
306 | runner.assert_script 'postModel.data.titleChars', to_be: chars
307 | end
308 | end
309 |
310 | tap do # watch test
311 | comment = Comment.first
312 | post = comment.post
313 | star = comment.stars.create user: User.first
314 | count = comment.stars.where('created_at != updated_at').count
315 | runner.eval_script <<~JAVASCRIPT
316 | global.postModel = new ArSyncModel({
317 | api: 'post',
318 | params: { id: #{post.id.to_json} },
319 | query: { comments: 'editedStarCount' }
320 | })
321 | JAVASCRIPT
322 | runner.assert_script 'postModel.data'
323 | runner.assert_script 'postModel.data.comments[0].editedStarCount', to_be: count
324 | star.update updated_at: 1.day.since
325 | runner.assert_script 'postModel.data.comments[0].editedStarCount', to_be: count + 1
326 | star.update updated_at: star.created_at
327 | runner.assert_script 'postModel.data.comments[0].editedStarCount', to_be: count
328 | end
329 |
330 | tap do # root array field test
331 | post1 = Post.first
332 | post2 = Post.second
333 | runner.eval_script <<~JAVASCRIPT
334 | global.postsModel = new ArSyncModel({
335 | api: 'Post',
336 | params: { ids: [#{post1.id.to_json}, #{post2.id.to_json}] },
337 | query: ['id', 'title']
338 | })
339 | JAVASCRIPT
340 | runner.assert_script 'postsModel.data'
341 | runner.assert_script 'postsModel.data[0].title', to_be: post1.title
342 | title2 = "title#{rand}"
343 | post1.update title: title2
344 | runner.assert_script 'postsModel.data[0].title', to_be: title2
345 | end
346 |
347 | tap do # model load from id
348 | post1 = Post.first
349 | post2 = Post.second
350 | runner.eval_script <<~JAVASCRIPT
351 | global.p1 = new ArSyncModel({ api: 'Post', id: #{post1.id.to_json}, query: 'title' })
352 | global.p2 = new ArSyncModel({ api: 'Post', id: #{post2.id.to_json}, query: 'title' })
353 | JAVASCRIPT
354 | runner.assert_script 'p1.data && p2.data'
355 | runner.assert_script '[p1.data.title, p2.data.title]', to_be: [post1.title, post2.title]
356 | p1title = "P1#{rand}"
357 | post1.update title: p1title
358 | runner.assert_script '[p1.data.title, p2.data.title]', to_be: [p1title, post2.title]
359 | p2title = "P2#{rand}"
360 | post2.update title: p2title
361 | runner.assert_script '[p1.data.title, p2.data.title]', to_be: [p1title, p2title]
362 | end
363 |
364 | tap do # load failed with notfound
365 | runner.eval_script <<~JAVASCRIPT
366 | global.p1 = new ArSyncModel({ api: 'post', params: { id: 0xffffffff }, query: 'title' }),
367 | global.p2 = new ArSyncModel({ api: 'Post', id: 0xffffffff, query: 'title' })
368 | global.p3 = new ArSyncModel({ api: 'nil', params: { id: 0xffffffff }, query: 'title' })
369 | JAVASCRIPT
370 | runner.assert_script '[p1.complete, p1.notfound, p1.data]', to_be: [true, true, nil]
371 | runner.assert_script '[p2.complete, p2.notfound, p2.data]', to_be: [true, true, nil]
372 | runner.assert_script '[p3.complete, p3.notfound, p3.data]', to_be: [true, true, nil]
373 | end
374 |
375 | tap do # sync self
376 | star = YellowStar.first
377 | runner.eval_script <<~JAVASCRIPT
378 | global.star = new ArSyncModel({ api: 'Star', id: #{star.id}, query: 'type' })
379 | JAVASCRIPT
380 | runner.assert_script 'star.data'
381 | runner.assert_script 'star.data.type', to_be: 'YellowStar'
382 | star.update!(type: 'RedStar')
383 | runner.assert_script 'star.data.type', to_be: 'RedStar'
384 | end
385 |
386 | tap do # sync root destroy
387 | star = YellowStar.first
388 | runner.eval_script <<~JAVASCRIPT
389 | global.destroyCalled = null
390 | global.star = new ArSyncModel({ api: 'Star', id: #{star.id}, query: 'type' })
391 | global.star.subscribe('destroy', () => { destroyCalled = { data: star.data, destroyed: star.destroyed } })
392 | JAVASCRIPT
393 | runner.assert_script 'star.data'
394 | runner.assert_script '!star.destroyed'
395 | star.destroy
396 | runner.assert_script 'destroyCalled', to_be: { 'data' => nil, 'destroyed' => true }
397 | end
398 |
399 | tap do # fetch with id test
400 | post = Post.first
401 | runner.eval_script <<~JAVASCRIPT
402 | global.data1 = {}
403 | global.data2 = {}
404 | ArSyncApi.syncFetch({ api: 'Post', id: #{post.id.to_json}, query: 'title' }).then(data => { global.data1 = data })
405 | ArSyncApi.fetch({ api: 'Post', id: #{post.id.to_json}, query: 'title' }).then(data => { global.data2 = data })
406 | JAVASCRIPT
407 | runner.assert_script 'data1.title', to_be: post.title
408 | runner.assert_script 'data2.title', to_be: post.title
409 | end
410 |
--------------------------------------------------------------------------------
/test/test_helper.js:
--------------------------------------------------------------------------------
1 | const ArSyncStore = require('../tree/ArSyncStore.js').default
2 | function dup(obj) { return JSON.parse(JSON.stringify(obj)) }
3 | function selectPatch(patches, keys) {
4 | return dup(patches).filter(arr => keys.indexOf(arr.key) >= 0)
5 | }
6 |
7 | function testDeepFrozen(obj) {
8 | if (!Object.isFrozen(obj)) return false
9 | if (!obj) return true
10 | if (typeof obj === 'array') {
11 | for (const el of obj) {
12 | if (!testDeepFrozen(el)) return false
13 | }
14 | } else if (typeof obj === 'object') {
15 | for (const el of Object.values(obj)) {
16 | if (!testDeepFrozen(el)) return false
17 | }
18 | }
19 | return true
20 | }
21 |
22 | function compareObject(a, b, path, key){
23 | if (!path) path = []
24 | if (key) (path = [].concat(path)).push(key)
25 | function log(message) {
26 | console.log(`${path.join('/')} ${message}`)
27 | }
28 | function withmessage(val){
29 | if (!val) log(`${JSON.stringify(a)} != ${JSON.stringify(b)}`)
30 | return val
31 | }
32 | if (a === b) return true
33 | if (!a || !b) return withmessage(false)
34 | if (a.constructor !== b.constructor) return withmessage(false)
35 | if (a.constructor === Array) {
36 | const len = Math.max(a.length, b.length)
37 | for (let i=0; i= a.length || i >= b.length) {
39 | log(`at index ${i}: ${JSON.stringify(a[i])} != ${JSON.stringify(b[i])})}`)
40 | return false
41 | }
42 | if (!compareObject(a[i], b[i], path, i)) return false
43 | }
44 | } else if (a.constructor === Object) {
45 | const akeys = Object.keys(a).sort()
46 | const bkeys = Object.keys(b).sort()
47 | if (akeys.join('') != bkeys.join('')) {
48 | log(`keys: ${JSON.stringify(akeys)} != ${JSON.stringify(bkeys)}`)
49 | return false
50 | }
51 | for (const i in a) {
52 | if (!compareObject(a[i], b[i], path, i)) return false
53 | }
54 | } else {
55 | return withmessage(a === b)
56 | }
57 | return true
58 | }
59 |
60 | function selectPatch(patches, keys) {
61 | return dup(patches).filter(arr => keys.indexOf(arr.key) >= 0)
62 | }
63 |
64 | function executeTest({ names, queries, keysList, initials, tests }) {
65 | for (const i in names) {
66 | const query = queries[i]
67 | const initial = initials[i]
68 | const { limit, order } = initial
69 | for (const immutable of [true, false]) {
70 | console.log(`Test: ${names[i]}${immutable ? ' immutable' : ''}`)
71 | try {
72 | const store = new ArSyncStore(query, dup(initial.data), { immutable, limit, order })
73 | for (const { patches, states } of tests) {
74 | const dataWas = store.data
75 | const dataWasCloned = dup(dataWas)
76 | store.batchUpdate(selectPatch(patches, initial.keys))
77 | const state = states[i]
78 | console.log(compareObject(store.data, state))
79 | if (immutable) console.log(compareObject(dataWas, dataWasCloned))
80 | if (immutable) console.log(testDeepFrozen(store.data))
81 | }
82 | } catch (e) {
83 | console.log(e)
84 | console.log(false)
85 | }
86 | }
87 | }
88 | }
89 |
90 | module.exports = executeTest
91 |
--------------------------------------------------------------------------------
/test/ts_test.rb:
--------------------------------------------------------------------------------
1 | require 'minitest/autorun'
2 | require 'ar_sync'
3 | require_relative 'model'
4 |
5 | class TsTest < Minitest::Test
6 | class Schema
7 | include ArSerializer::Serializable
8 | serializer_field :currentUser, type: User
9 | serializer_field :users, type: [User]
10 | serializer_field 'User', type: [User], params_type: { ids: [:int] }
11 | end
12 |
13 | def test_ts_type_generate
14 | ArSync::TypeScript.generate_type_definition User
15 | end
16 |
17 | def test_typed_files
18 | dir = 'test/generated_typed_files'
19 | Dir.mkdir dir unless Dir.exist? dir
20 | ArSync::TypeScript.generate_typed_files Schema, dir: dir
21 | %w[ArSyncApi.ts ArSyncModel.ts DataTypeFromRequest.ts hooks.ts].each do |file|
22 | path = File.join dir, file
23 | File.write path, File.read(path).gsub('ar_sync/', '../../src/')
24 | end
25 | output = `./node_modules/typescript/bin/tsc --strict --lib es2017 --noEmit test/type_test.ts`
26 | output = output.lines.grep(/type_test/)
27 | puts output
28 | assert output.empty?
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "strict": true
5 | },
6 | "include": ["."],
7 | }
8 |
--------------------------------------------------------------------------------
/test/type_test.ts:
--------------------------------------------------------------------------------
1 | import ArSyncModel from './generated_typed_files/ArSyncModel'
2 | import { useArSyncModel, useArSyncFetch } from './generated_typed_files/hooks'
3 | import ActionCableAdapter from '../src/core/ActionCableAdapter'
4 | import * as ActionCable from 'actioncable'
5 | ArSyncModel.setConnectionAdapter(new ActionCableAdapter(ActionCable))
6 |
7 | type IsEqual = [T, U] extends [U, T] ? true : false
8 | function isOK(): T | undefined { return }
9 | type IsStrictMode = string | null extends string ? false : true
10 | type TypeIncludes = IsEqual, U>
11 | type HasExtraField = IsEqual['error']['extraFields'], U>
12 | isOK()
13 |
14 | const [hooksData1] = useArSyncModel({ api: 'currentUser', query: 'id' })
15 | isOK>()
16 | const [hooksData2] = useArSyncModel({ api: 'currentUser', query: { '*': true, foo: true } })
17 | isOK>()
18 | const [hooksData3] = useArSyncFetch({ api: 'currentUser', query: 'id' })
19 | isOK>()
20 | const [hooksData4] = useArSyncFetch({ api: 'currentUser', query: { '*': true, foo: true } })
21 | isOK>()
22 |
23 | const data1 = new ArSyncModel({ api: 'currentUser', query: 'id' }).data!
24 | isOK>()
25 | const data2 = new ArSyncModel({ api: 'currentUser', query: ['id', 'name'] }).data!
26 | isOK>()
27 | const data3 = new ArSyncModel({ api: 'currentUser', query: '*' }).data!
28 | isOK>()
29 | const data4 = new ArSyncModel({ api: 'currentUser', query: { posts: 'id' } }).data!
30 | isOK>()
31 | const data5 = new ArSyncModel({ api: 'currentUser', query: { posts: '*' } }).data!
32 | data5.posts[0].id; data5.posts[0].user; data5.posts[0].body
33 | isOK>()
34 | const data6 = new ArSyncModel({ api: 'currentUser', query: { posts: { '*': true, comments: 'user' } } }).data!
35 | isOK>()
36 | const data7 = new ArSyncModel({ api: 'currentUser', query: { name: true, poosts: true } }).data!
37 | isOK>()
38 | const data8 = new ArSyncModel({ api: 'currentUser', query: { posts: { id: true, commmments: true, titllle: true } } }).data!
39 | isOK>()
40 | const data9 = new ArSyncModel({ api: 'currentUser', query: { '*': true, posts: { id: true, commmments: true } } }).data!
41 | isOK>()
42 | const data10 = new ArSyncModel({ api: 'users', query: { '*': true, posts: { id: true, comments: '*' } } }).data!
43 | isOK>()
44 | const data11 = new ArSyncModel({ api: 'users', query: { '*': true, posts: { id: true, comments: '*', commmments: true } } }).data!
45 | isOK>()
46 | const data12 = new ArSyncModel({ api: 'currentUser', query: { posts: { params: { first: 4 }, attributes: 'title' } } }).data!
47 | isOK>()
48 | const data13 = new ArSyncModel({ api: 'currentUser', query: { posts: { params: { first: 4 }, attributes: ['id', 'title'] } } }).data!
49 | isOK>()
50 | const data14 = new ArSyncModel({ api: 'currentUser', query: { posts: { params: { first: 4 }, attributes: { id: true, title: true } } } }).data!
51 | isOK>()
52 | const data15 = new ArSyncModel({ api: 'currentUser', query: { posts: ['id', 'title'] } } as const).data!
53 | isOK>()
54 | const data16 = new ArSyncModel({ api: 'User', id: 1, query: 'name' }).data!
55 | isOK>()
56 | const data17 = new ArSyncModel({ api: 'currentUser', query: 'postOrNull' }).data!
57 | isOK>()
58 | const data18 = new ArSyncModel({ api: 'currentUser', query: { postOrNull: 'title' } }).data!
59 | isOK>()
60 | const data19 = new ArSyncModel({ api: 'currentUser', query: { '*': true, postOrNull: 'title' } }).data!
61 | isOK>()
62 |
63 | const model = new ArSyncModel({ api: 'currentUser', query: { posts: ['id', 'title'] } } as const)
64 | let digId = model.dig(['posts', 0, 'id'] as const)
65 | isOK>()
66 | let digTitle = model.dig(['posts', 0, 'title'] as const)
67 | isOK>()
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": ".",
4 | "rootDir": "./src",
5 | "module": "commonjs",
6 | "target": "es5",
7 | "lib": ["es2017", "dom"],
8 | "strictNullChecks": true,
9 | "noUnusedLocals": true,
10 | "noUnusedParameters": true,
11 | "declaration": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/ar_sync.js.erb:
--------------------------------------------------------------------------------
1 | (function (){
2 | var exports = {}
3 | var modules = {}
4 | function require(name) { return modules[name] }
5 | <% require = -> (path) { path = File.dirname(__FILE__) + "/../../../#{path}"; depend_on path; File.read path } %>
6 | <%= require.call 'core/ArSyncApi.js' %>
7 | window.ArSyncAPI = exports.default
8 | modules['./ArSyncApi'] = { default: exports.default }
9 | <%= require.call 'core/ConnectionManager.js' %>
10 | modules['./ConnectionManager'] = { default: exports.default }
11 | <%= require.call 'core/ArSyncStore.js' %>
12 | modules['./ArSyncStore'] = { default: exports.default }
13 | <%= require.call 'core/ArSyncModel.js' %>
14 | modules['./ArSyncModel'] = { default: exports.default }
15 | window.ArSyncModel = exports.default
16 | }())
17 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/ar_sync_action_cable_adapter.js.erb:
--------------------------------------------------------------------------------
1 | (function (){
2 | var modules = { actioncable: window.ActionCable }
3 | var exports = {}
4 | function require(name) { return modules[name] }
5 | <%= File.read File.dirname(__FILE__) + '/../../../core/ActionCableAdapter.js' %>
6 | window.ArSyncActionCableAdapter = ActionCableAdapter
7 | }())
8 |
--------------------------------------------------------------------------------