├── .github └── workflows │ └── test.yml ├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── ar_sync.gemspec ├── bin ├── console └── setup ├── core ├── ActionCableAdapter.d.ts ├── ActionCableAdapter.js ├── ArSyncApi.d.ts ├── ArSyncApi.js ├── ArSyncModel.d.ts ├── ArSyncModel.js ├── ArSyncStore.d.ts ├── ArSyncStore.js ├── ConnectionAdapter.d.ts ├── ConnectionAdapter.js ├── ConnectionManager.d.ts ├── ConnectionManager.js ├── DataType.d.ts ├── DataType.js ├── hooks.d.ts └── hooks.js ├── gemfiles ├── Gemfile-rails-6 ├── Gemfile-rails-7 └── Gemfile-rails-8 ├── index.d.ts ├── index.js ├── lib ├── ar_sync.rb ├── ar_sync │ ├── class_methods.rb │ ├── collection.rb │ ├── config.rb │ ├── core.rb │ ├── instance_methods.rb │ ├── rails.rb │ ├── type_script.rb │ └── version.rb └── generators │ └── ar_sync │ ├── install │ └── install_generator.rb │ └── types │ └── types_generator.rb ├── package-lock.json ├── package.json ├── src ├── core │ ├── ActionCableAdapter.ts │ ├── ArSyncApi.ts │ ├── ArSyncModel.ts │ ├── ArSyncStore.ts │ ├── ConnectionAdapter.ts │ ├── ConnectionManager.ts │ ├── DataType.ts │ └── hooks.ts └── index.ts ├── test ├── db.rb ├── helper │ ├── connection_adapter.js │ ├── test_runner.js │ └── test_runner.rb ├── model.rb ├── request_test.rb ├── seed.rb ├── sync_test.rb ├── test_helper.js ├── ts_test.rb ├── tsconfig.json └── type_test.ts ├── tsconfig.json └── vendor └── assets └── javascripts ├── ar_sync.js.erb └── ar_sync_action_cable_adapter.js.erb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby: [ '3.2', '3.3', '3.4' ] 9 | gemfiles: 10 | - gemfiles/Gemfile-rails-6 11 | - gemfiles/Gemfile-rails-7 12 | - gemfiles/Gemfile-rails-8 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ${{ matrix.ruby }} 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '14.x' 22 | - run: | 23 | sudo apt-get update 24 | sudo apt-get install -y libsqlite3-dev 25 | - run: bundle install --gemfile ${{ matrix.gemfiles }} --jobs 4 --retry 3 26 | - run: npm install 27 | - run: npm run build 28 | - run: bundle exec --gemfile ${{ matrix.gemfiles }} rake 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /test/*.sqlite3 10 | /test/*.sqlite3-* 11 | /test/generated_* 12 | .ruby-version 13 | /node_modules/ 14 | tsconfig.tsbuildinfo 15 | Gemfile.lock 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.4.2 5 | before_install: gem install bundler -v 1.16.0 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 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 7 | gem 'sqlite3' 8 | gem 'ar_serializer' 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 tompng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ArSync - Reactive Programming with Ruby on Rails 2 | 3 | Frontend JSON data will be synchronized with ActiveRecord. 4 | 5 | - Provides an json api with query(shape of the json) 6 | - Send a notificaiton with ActionCable and automaticaly updates the data 7 | 8 | ## Installation 9 | 10 | 1. Add this line to your application's Gemfile: 11 | ```ruby 12 | gem 'ar_sync' 13 | ``` 14 | 15 | 2. Run generator 16 | ```shell 17 | rails g ar_sync:install 18 | ``` 19 | 20 | ## Usage 21 | 22 | 1. Define parent, data, has_one, has_many to your models 23 | ```ruby 24 | class User < ApplicationRecord 25 | has_many :posts 26 | ... 27 | sync_has_data :id, :name 28 | sync_has_many :posts 29 | end 30 | 31 | class Post < ApplicationRecord 32 | belongs_to :user 33 | ... 34 | sync_parent :user, inverse_of: :posts 35 | sync_has_data :id, :title, :body, :createdAt, :updatedAt 36 | sync_has_one :user, only: [:id, :name] 37 | end 38 | ``` 39 | 40 | 2. Define apis 41 | ```ruby 42 | # app/models/sync_schema.rb 43 | class SyncSchema < ArSync::SyncSchemaBase 44 | # User-defined api 45 | serializer_field :my_simple_profile_api do |current_user| 46 | current_user 47 | end 48 | serializer_field :my_simple_friends_api do |current_user, age:| 49 | current_user.friends.where(age: age) 50 | end 51 | # Reload api (field name = classname, params = `ids:`) 52 | serializer_field :User do |current_user, ids:| 53 | User.where(condition).where id: ids 54 | end 55 | serializer_field :Post do |current_user, ids:| 56 | Post.where(condition).where id: ids 57 | end 58 | end 59 | ``` 60 | 61 | 3. Write your view 62 | ```html 63 | 64 | 73 |
74 |

{{user.name}}'s page

75 | 83 |
84 | 85 | 86 | 87 |
88 |
89 | ``` 90 | Now, your view and ActiveRecord are synchronized. 91 | 92 | 93 | # With typescript 94 | 1. Add `"ar_sync": "git://github.com/tompng/ar_sync.git"` to your package.json 95 | 96 | 2. Generate types 97 | ```shell 98 | rails g ar_sync:types path_to_generated_code_dir/ 99 | ``` 100 | 101 | 3. Connection Setting 102 | ```ts 103 | import ArSyncModel from 'path_to_generated_code_dir/ArSyncModel' 104 | import ActionCableAdapter from 'ar_sync/core/ActionCableAdapter' 105 | import * as ActionCable from 'actioncable' 106 | ArSyncModel.setConnectionAdapter(new ActionCableAdapter(ActionCable)) 107 | // ArSyncModel.setConnectionAdapter(new MyCustomConnectionAdapter) // If you are using other transports 108 | ``` 109 | 110 | 4. Write your components 111 | ```ts 112 | import { useArSyncModel } from 'path_to_generated_code_dir/hooks' 113 | const HelloComponent: React.FC = () => { 114 | const [user, status] = useArSyncModel({ 115 | api: 'my_simple_profile_api', 116 | query: ['id', 'name'] 117 | }) 118 | // user // => { id: number; name: string } | null 119 | if (!user) return <>loading... 120 | // user.id // => number 121 | // user.name // => string 122 | // user.foobar // => compile error 123 | return

Hello, {user.name}!

124 | } 125 | ``` 126 | 127 | # Examples 128 | https://github.com/tompng/ar_sync_sampleapp 129 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /ar_sync.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'ar_sync/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'ar_sync' 7 | spec.version = ArSync::VERSION 8 | spec.authors = ['tompng'] 9 | spec.email = ['tomoyapenguin@gmail.com'] 10 | 11 | spec.summary = %(ActiveRecord - JavaScript Sync) 12 | spec.description = %(ActiveRecord data synchronized with frontend DataStore) 13 | spec.homepage = "https://github.com/tompng/#{spec.name}" 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features|sampleapp)/}) 18 | end 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'activerecord' 24 | spec.add_dependency 'ar_serializer' 25 | %w[rake sqlite3 activerecord-import].each do |gem_name| 26 | spec.add_development_dependency gem_name 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'ar_sync' 5 | require 'irb' 6 | require_relative '../test/model' 7 | ArSync.on_notification do |events| 8 | puts "\e[1m#{events.inspect}\e[m" 9 | end 10 | 11 | IRB.start 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /core/ActionCableAdapter.d.ts: -------------------------------------------------------------------------------- 1 | import ConnectionAdapter from './ConnectionAdapter'; 2 | declare module ActionCable { 3 | function createConsumer(): Cable; 4 | interface Cable { 5 | subscriptions: Subscriptions; 6 | } 7 | interface CreateMixin { 8 | connected: () => void; 9 | disconnected: () => void; 10 | received: (obj: any) => void; 11 | } 12 | interface ChannelNameWithParams { 13 | channel: string; 14 | [key: string]: any; 15 | } 16 | interface Subscriptions { 17 | create(channel: ChannelNameWithParams, obj: CreateMixin): Channel; 18 | } 19 | interface Channel { 20 | unsubscribe(): void; 21 | perform(action: string, data: {}): void; 22 | send(data: any): boolean; 23 | } 24 | } 25 | export default class ActionCableAdapter implements ConnectionAdapter { 26 | connected: boolean; 27 | _cable: ActionCable.Cable; 28 | actionCableClass: typeof ActionCable; 29 | constructor(actionCableClass: typeof ActionCable); 30 | subscribe(key: string, received: (data: any) => void): ActionCable.Channel; 31 | ondisconnect(): void; 32 | onreconnect(): void; 33 | } 34 | export {}; 35 | -------------------------------------------------------------------------------- /core/ActionCableAdapter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var ActionCableAdapter = /** @class */ (function () { 4 | function ActionCableAdapter(actionCableClass) { 5 | this.connected = true; 6 | this.actionCableClass = actionCableClass; 7 | this.subscribe(Math.random().toString(), function () { }); 8 | } 9 | ActionCableAdapter.prototype.subscribe = function (key, received) { 10 | var _this = this; 11 | var disconnected = function () { 12 | if (!_this.connected) 13 | return; 14 | _this.connected = false; 15 | _this.ondisconnect(); 16 | }; 17 | var connected = function () { 18 | if (_this.connected) 19 | return; 20 | _this.connected = true; 21 | _this.onreconnect(); 22 | }; 23 | if (!this._cable) 24 | this._cable = this.actionCableClass.createConsumer(); 25 | return this._cable.subscriptions.create({ channel: 'SyncChannel', key: key }, { received: received, disconnected: disconnected, connected: connected }); 26 | }; 27 | ActionCableAdapter.prototype.ondisconnect = function () { }; 28 | ActionCableAdapter.prototype.onreconnect = function () { }; 29 | return ActionCableAdapter; 30 | }()); 31 | exports.default = ActionCableAdapter; 32 | -------------------------------------------------------------------------------- /core/ArSyncApi.d.ts: -------------------------------------------------------------------------------- 1 | declare function apiBatchFetch(endpoint: string, requests: object[]): Promise; 2 | declare type Request = { 3 | api: string; 4 | params?: any; 5 | query: any; 6 | id?: number; 7 | }; 8 | declare const ArSyncApi: { 9 | domain: string | null; 10 | _batchFetch: typeof apiBatchFetch; 11 | fetch: (request: Request) => Promise; 12 | syncFetch: (request: Request) => Promise; 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 | --------------------------------------------------------------------------------