teamplay 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,14 +30,37 @@ import connect from 'teamplay/connect'
30
30
  connect()
31
31
  ```
32
32
 
33
- And on the server, manually create a [ShareDB's backend](https://share.github.io/sharedb/api/backend) and create a connection handler for WebSockets:
33
+ On the server you need to create the teamplay's backend and then create a connection handler for WebSockets:
34
34
 
35
35
  ```js
36
- import { initConnection } from 'teamplay/server'
37
- const { upgrade } = initConnection(backend) // ShareDB's Backend instance
36
+ import { createBackend, initConnection } from 'teamplay/server'
37
+ const backend = createBackend()
38
+ const { upgrade } = initConnection(backend)
38
39
  server.on('upgrade', upgrade) // Node's 'http' server instance
39
40
  ```
40
41
 
42
+ - for production use it's recommended to use MongoDB. It's gonna be automatically used if you set the env var `MONGO_URL`
43
+ - when deploying to a cluster with multiple instances you also have to set the env var `REDIS_URL` (Redis)
44
+
45
+ Without setting `MONGO_URL` the alternative `mingo` mock is used instead which persists data into an SQLite file `local.db` in the root of your project.
46
+
47
+ > [!NOTE]
48
+ > teamplay's `createBackend()` is a wrapper around creating a [ShareDB's backend](https://share.github.io/sharedb/api/backend).
49
+ > You can instead manually create a ShareDB backend yourself and pass it to `initConnection()`.
50
+ > `ShareDB` is re-exported from `teamplay/server`, you can get it as `import { ShareDB } from 'teamplay/server'`
51
+
52
+ ## `initConnection(backend, options)`
53
+
54
+ **`backend`** - ShareDB backend instance
55
+
56
+ **`options`**:
57
+
58
+ ### `fetchOnly` (default: `true`)
59
+
60
+ By default all subscriptions on the server are not reactive. This is strongly recommended.
61
+
62
+ If you need the subscriptions to reactively update data whenever it changes (the same way as they work on client-side), pass `{ fetchOnly: false }`.
63
+
41
64
  ## Usage
42
65
 
43
66
  TBD
@@ -45,16 +68,17 @@ TBD
45
68
 
46
69
  ## Examples
47
70
 
71
+ For a simple working react app see [/example](/example)
72
+
48
73
  ### Simplest example with server synchronization
49
74
 
50
75
  On the client we `connect()` to the server, and we have to wrap each React component into `observer()`:
51
76
 
52
77
  ```js
53
78
  // client.js
79
+ import { createRoot } from 'react-dom/client'
54
80
  import connect from 'teamplay/connect'
55
81
  import { observer, $, sub } from 'teamplay'
56
- import { createRoot } from 'react-dom/client'
57
- import { createElement as el } from 'react'
58
82
 
59
83
  connect()
60
84
 
@@ -62,14 +86,12 @@ const App = observer(({ userId }) => {
62
86
  const $user = sub($.users[userId])
63
87
  if (!$user.get()) throw $user.set({ points: 0 })
64
88
  const { $points } = $user
65
- const onClick = () => $points.set($points.get() + 1)
66
- return el('button', { onClick }, 'Points: ' + $points.get())
89
+ const increment = () => $points.set($points.get() + 1)
90
+ return <button onClick={increment}>Points: {$points.get()}</button>
67
91
  })
68
92
 
69
93
  const container = document.body.appendChild(document.createElement('div'))
70
- createRoot(container).render(
71
- el(App, { userId: '_1' })
72
- )
94
+ createRoot(container).render(<App userId='_1' />)
73
95
  ```
74
96
 
75
97
  On the server we create the ShareDB backend and initialize the WebSocket connections handler:
@@ -77,21 +99,19 @@ On the server we create the ShareDB backend and initialize the WebSocket connect
77
99
  ```js
78
100
  // server.js
79
101
  import http from 'http'
80
- import { ShareDB, initConnection } from 'teamplay/server'
102
+ import { createBackend, initConnection } from 'teamplay/server'
81
103
 
82
104
  const server = http.createServer() // you can pass expressApp here if needed
83
- const backend = new ShareDB()
105
+ const backend = createBackend()
84
106
  const { upgrade } = initConnection(backend)
85
107
 
86
108
  server.on('upgrade', upgrade)
87
109
 
88
- server.listen(3000)
110
+ server.listen(3000, () => {
111
+ console.log('Server started. Open http://localhost:3000 in your browser')
112
+ })
89
113
  ```
90
114
 
91
- ShareDB is a re-export of [`sharedb`](https://github.com/share/sharedb) library, check its docs for more info.
92
- - for persistency and queries support pass [`sharedb-mongo`](https://github.com/share/sharedb-mongo) (which uses MongoDB) as `{ db }`
93
- - when deploying to a cluster with multiple instances you also have to provide `{ pubsub }` like [`sharedb-redis-pubsub`](https://github.com/share/sharedb-redis-pubsub) (which uses Redis)
94
-
95
115
  ## License
96
116
 
97
117
  MIT
package/index.js CHANGED
@@ -16,9 +16,8 @@ export default $
16
16
  export { default as sub } from './react/universalSub.js'
17
17
  export { default as observer } from './react/observer.js'
18
18
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly } from './orm/connection.js'
19
- export * from './schema/associations.js'
20
- export { default as GUID_PATTERN } from './schema/GUID_PATTERN.js'
21
- export { default as pickFormFields } from './schema/pickFormFields.js'
19
+ export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
20
+ export { aggregation, aggregationHeader } from '@teamplay/utils/aggregation'
22
21
 
23
22
  export function getRootSignal (options) {
24
23
  return _getRootSignal({
package/orm/Cache.js CHANGED
@@ -1,3 +1,6 @@
1
+ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
2
+ import WeakRef, { destroyMockWeakRef } from '../utils/MockWeakRef.js'
3
+
1
4
  export default class Cache {
2
5
  constructor () {
3
6
  this.cache = new Map()
@@ -20,6 +23,7 @@ export default class Cache {
20
23
  }
21
24
 
22
25
  delete (key) {
26
+ destroyMockWeakRef(this.cache.get(key)) // TODO: remove this when WeakRef is available in RN
23
27
  this.cache.delete(key)
24
28
  }
25
29
 
package/orm/Doc.js CHANGED
@@ -2,6 +2,7 @@ import { isObservable, observable } from '@nx-js/observer-util'
2
2
  import { set as _set, del as _del } from './dataTree.js'
3
3
  import { SEGMENTS } from './Signal.js'
4
4
  import { getConnection, fetchOnly } from './connection.js'
5
+ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
5
6
 
6
7
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
7
8
 
package/orm/Query.js CHANGED
@@ -4,6 +4,7 @@ import { SEGMENTS } from './Signal.js'
4
4
  import getSignal from './getSignal.js'
5
5
  import { getConnection, fetchOnly } from './connection.js'
6
6
  import { docSubscriptions } from './Doc.js'
7
+ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
7
8
 
8
9
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
9
10
  export const PARAMS = Symbol('query params')
package/orm/Reaction.js CHANGED
@@ -2,6 +2,7 @@ import { observe, unobserve } from '@nx-js/observer-util'
2
2
  import { SEGMENTS } from './Signal.js'
3
3
  import { set as _set, del as _del } from './dataTree.js'
4
4
  import { LOCAL } from './Value.js'
5
+ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
5
6
 
6
7
  // this is `let` to be able to directly change it if needed in tests or in the app
7
8
  export let DELETION_DELAY = 0 // eslint-disable-line prefer-const
package/orm/Value.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SEGMENTS } from './Signal.js'
2
2
  import { set as _set, del as _del } from './dataTree.js'
3
+ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
3
4
 
4
5
  export const LOCAL = '$local'
5
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,7 +9,8 @@
9
9
  "./connect": "./connect/index.js",
10
10
  "./server": "./server.js",
11
11
  "./connect-test": "./connect/test.js",
12
- "./cache": "./cache.js"
12
+ "./cache": "./cache.js",
13
+ "./schema": "./schema.js"
13
14
  },
14
15
  "publishConfig": {
15
16
  "access": "public"
@@ -22,9 +23,12 @@
22
23
  },
23
24
  "dependencies": {
24
25
  "@nx-js/observer-util": "^4.1.3",
25
- "@teamplay/cache": "^0.1.4",
26
- "@teamplay/channel": "^0.1.4",
27
- "@teamplay/debug": "^0.1.4",
26
+ "@teamplay/backend": "^0.1.6",
27
+ "@teamplay/cache": "^0.1.6",
28
+ "@teamplay/channel": "^0.1.6",
29
+ "@teamplay/debug": "^0.1.6",
30
+ "@teamplay/schema": "^0.1.6",
31
+ "@teamplay/utils": "^0.1.6",
28
32
  "diff-match-patch": "^1.0.5",
29
33
  "events": "^3.3.0",
30
34
  "json0-ot-diff": "^1.1.2",
@@ -37,6 +41,8 @@
37
41
  "@testing-library/react": "^15.0.7",
38
42
  "jest": "^29.7.0",
39
43
  "jest-environment-jsdom": "^29.7.0",
44
+ "react": "^18.3.1",
45
+ "react-dom": "^18.3.1",
40
46
  "sharedb-mingo-memory": "^3.0.1"
41
47
  },
42
48
  "peerDependencies": {
@@ -58,5 +64,5 @@
58
64
  ]
59
65
  },
60
66
  "license": "MIT",
61
- "gitHead": "3c6c4f6dd9fcfaa4e2a7f28c5ce989e6f7c4b133"
67
+ "gitHead": "97787add00d5543476304ab577a3550da916debe"
62
68
  }
package/schema.js ADDED
@@ -0,0 +1 @@
1
+ export * from '@teamplay/schema'
package/server.js CHANGED
@@ -2,11 +2,18 @@ import createChannel from '@teamplay/channel/server'
2
2
  import { connection, setConnection, setFetchOnly } from './orm/connection.js'
3
3
 
4
4
  export { default as ShareDB } from 'sharedb'
5
+ export {
6
+ default as createBackend,
7
+ mongo, mongoClient, createMongoIndex, redis, redlock, sqlite
8
+ } from '@teamplay/backend'
5
9
 
6
- export default function initConnection (backend, options) {
10
+ export function initConnection (backend, {
11
+ fetchOnly = true,
12
+ ...options
13
+ } = {}) {
7
14
  if (!backend) throw Error('backend is required')
8
15
  if (connection) throw Error('Connection already exists')
9
16
  setConnection(backend.connect())
10
- setFetchOnly(true)
17
+ setFetchOnly(fetchOnly)
11
18
  return createChannel(backend, options)
12
19
  }
@@ -0,0 +1,66 @@
1
+ export const REGISTRY_FINALIZE_AFTER = 10_000
2
+ export const REGISTRY_SWEEP_INTERVAL = 10_000
3
+
4
+ // This is a mock implementation of FinalizationRegistry that uses setTimeout to
5
+ // schedule the sweep of outdated objects.
6
+ // It is used in environments where FinalizationRegistry is not available.
7
+ // For now we permanently keep the values in the registry until they are
8
+ // manually unregistered since we don't have a way to know when the object is
9
+ // no longer needed. In the future we might add the control logic to properly
10
+ // invalidate the objects.
11
+ export let PERMANENT = true
12
+ export function setPermanent (permanent) { PERMANENT = permanent }
13
+
14
+ export class TimerBasedFinalizationRegistry {
15
+ registrations = new Map()
16
+ sweepTimeout
17
+
18
+ constructor (finalize) {
19
+ this.finalize = finalize
20
+ }
21
+
22
+ // Token is actually required with this impl
23
+ register (target, value, token) {
24
+ this.registrations.set(token, {
25
+ value,
26
+ registeredAt: Date.now()
27
+ })
28
+ if (!PERMANENT) this.scheduleSweep()
29
+ }
30
+
31
+ unregister (token) {
32
+ this.registrations.delete(token)
33
+ }
34
+
35
+ // Bound so it can be used directly as setTimeout callback.
36
+ sweep = (maxAge = REGISTRY_FINALIZE_AFTER) => {
37
+ // cancel timeout so we can force sweep anytime
38
+ clearTimeout(this.sweepTimeout)
39
+ this.sweepTimeout = undefined
40
+
41
+ const now = Date.now()
42
+ this.registrations.forEach((registration, token) => {
43
+ if (now - registration.registeredAt >= maxAge) {
44
+ this.finalize(registration.value)
45
+ this.registrations.delete(token)
46
+ }
47
+ })
48
+
49
+ if (this.registrations.size > 0) {
50
+ this.scheduleSweep()
51
+ }
52
+ }
53
+
54
+ // Bound so it can be exported directly as clearTimers test utility.
55
+ finalizeAllImmediately = () => {
56
+ this.sweep(0)
57
+ }
58
+
59
+ scheduleSweep () {
60
+ if (this.sweepTimeout === undefined) {
61
+ this.sweepTimeout = setTimeout(this.sweep, REGISTRY_SWEEP_INTERVAL)
62
+ }
63
+ }
64
+ }
65
+
66
+ export default (typeof FinalizationRegistry !== 'undefined' ? FinalizationRegistry : TimerBasedFinalizationRegistry)
@@ -0,0 +1,16 @@
1
+ export class MockWeakRef {
2
+ constructor (value) {
3
+ this.value = value
4
+ }
5
+
6
+ deref () {
7
+ return this.value
8
+ }
9
+ }
10
+
11
+ export function destroyMockWeakRef (weakRef) {
12
+ if (!(weakRef instanceof MockWeakRef)) return
13
+ weakRef.value = undefined
14
+ }
15
+
16
+ export default (typeof WeakRef !== 'undefined' ? WeakRef : MockWeakRef)
package/CHANGELOG.md DELETED
@@ -1,45 +0,0 @@
1
- # v0.1.4 (Thu May 23 2024)
2
-
3
- #### 🐛 Bug Fix
4
-
5
- - fix: rename old references to startupjs packages into teamplay packages ([@cray0000](https://github.com/cray0000))
6
-
7
- #### Authors: 1
8
-
9
- - Pavel Zhukov ([@cray0000](https://github.com/cray0000))
10
-
11
- ---
12
-
13
- # v0.1.3 (Thu May 23 2024)
14
-
15
- #### 🐛 Bug Fix
16
-
17
- - fix: add 'events' since sharedb client requires it to work correctly ([@cray0000](https://github.com/cray0000))
18
-
19
- #### Authors: 1
20
-
21
- - Pavel Zhukov ([@cray0000](https://github.com/cray0000))
22
-
23
- ---
24
-
25
- # v0.1.2 (Thu May 23 2024)
26
-
27
- #### 🐛 Bug Fix
28
-
29
- - fix: re-export cache as 'teamplay/cache' ([@cray0000](https://github.com/cray0000))
30
-
31
- #### Authors: 1
32
-
33
- - Pavel Zhukov ([@cray0000](https://github.com/cray0000))
34
-
35
- ---
36
-
37
- # v0.1.1 (Thu May 23 2024)
38
-
39
- #### 🐛 Bug Fix
40
-
41
- - fix: dummy. trigger version bump ([@cray0000](https://github.com/cray0000))
42
-
43
- #### Authors: 1
44
-
45
- - Pavel Zhukov ([@cray0000](https://github.com/cray0000))
@@ -1 +0,0 @@
1
- export default '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
@@ -1,51 +0,0 @@
1
- import GUID_PATTERN from './GUID_PATTERN.js'
2
-
3
- export function belongsTo (collectionName) {
4
- return {
5
- type: 'string',
6
- pattern: GUID_PATTERN,
7
- $association: {
8
- type: 'belongsTo',
9
- collection: collectionName
10
- }
11
- }
12
- }
13
-
14
- export function hasMany (collectionName) {
15
- return {
16
- type: 'array',
17
- items: {
18
- type: 'string',
19
- pattern: GUID_PATTERN
20
- },
21
- $association: {
22
- type: 'hasMany',
23
- collection: collectionName
24
- }
25
- }
26
- }
27
-
28
- export function hasManyFlags (collectionName) {
29
- return {
30
- type: 'object',
31
- patternProperties: {
32
- [GUID_PATTERN]: { type: 'boolean' }
33
- },
34
- additionalProperties: false,
35
- $association: {
36
- type: 'hasManyFlags',
37
- collection: collectionName
38
- }
39
- }
40
- }
41
-
42
- export function hasOne (collectionName) {
43
- return {
44
- type: 'string',
45
- pattern: GUID_PATTERN,
46
- $association: {
47
- type: 'hasOne',
48
- collection: collectionName
49
- }
50
- }
51
- }
@@ -1,104 +0,0 @@
1
- /**
2
- * Pick properties from json-schema to be used in a form.
3
- * Supports simplified schema (just the properties object) and full schema.
4
- * Performs extra transformations like auto-generating `label`.
5
- * `createdAt`, `updatedAt`, `id`, `_id` fields are excluded by default.
6
- * @param {Object} schema
7
- * @param {Object|Array} options - exclude or include fields. If array, it's the same as passing { include: [...] }
8
- * @param {Array} options.include - list of fields to pick (default: all)
9
- * @param {Array} options.exclude - list of fields to exclude (default: none)
10
- * @param {Boolean} options.freeze - whether to deep freeze the result (default: true)
11
- */
12
- export default function pickFormFields (schema, options) {
13
- try {
14
- let include, exclude, freeze
15
- if (Array.isArray(options)) {
16
- include = options
17
- } else {
18
- ;({ include, exclude, freeze = true } = options || {})
19
- }
20
- exclude ??= []
21
- if (!schema) throw Error('pickFormFields: schema is required')
22
- schema = JSON.parse(JSON.stringify(schema))
23
- if (schema.type === 'object') {
24
- schema = schema.properties
25
- }
26
- for (const key in schema) {
27
- if (shouldIncludeField(key, schema[key], { include, exclude })) {
28
- const field = schema[key]
29
- if (!field.label) field.label = camelCaseToLabel(key)
30
- } else {
31
- delete schema[key]
32
- }
33
- }
34
- if (freeze) return new Proxy(schema, deepFreezeHandler)
35
- return schema
36
- } catch (err) {
37
- throw Error(`
38
- pickFormFields: ${err.message}
39
- schema:\n${JSON.stringify(schema, null, 2)}
40
- `)
41
- }
42
- }
43
-
44
- // Proxy handlers to deep freeze schema to prevent accidental mutations.
45
- // For this, when we .get() a property, we also return the same recursive Proxy handler if it's an object.
46
- const deepFreezeHandler = {
47
- get (target, prop) {
48
- const value = target[prop]
49
- if (typeof value === 'object' && value !== null) {
50
- return new Proxy(value, deepFreezeHandler)
51
- }
52
- return value
53
- },
54
- set () {
55
- throw Error(ERRORS.schemaIsFrozen)
56
- }
57
- }
58
-
59
- function shouldIncludeField (key, field, { include, exclude = [] } = {}) {
60
- if (!field) throw Error(`field "${key}" does not have a schema definition`)
61
- if (include?.includes(key)) return true
62
- if (exclude.includes(key)) return false
63
- // if field has 'input' specified then it's an explicit indicator that it can be used in forms,
64
- // so the default exclusion rules don't apply
65
- if (!field.input) {
66
- // exclude some meta fields by default
67
- if (DEFAULT_EXCLUDE_FORM_FIELDS.includes(key)) return false
68
- // exclude foreign keys by default
69
- // Foreign keys have a custom `$association` property set by belongsTo/hasMany/hasOne helpers
70
- if (field.$association) return false
71
- }
72
- // if include array is not explicitly set, include all fields by default
73
- if (!include) return true
74
- return false
75
- }
76
-
77
- const DEFAULT_EXCLUDE_FORM_FIELDS = ['id', '_id', 'createdAt', 'updatedAt']
78
-
79
- // split into words, capitalize first word, make others lowercase
80
- function camelCaseToLabel (str) {
81
- return str
82
- .replace(/([A-Z])/g, ' $1')
83
- .toLowerCase()
84
- .replace(/^./, (s) => s.toUpperCase())
85
- }
86
-
87
- const ERRORS = {
88
- schemaIsFrozen: `
89
- Form fields are immutable.
90
- If you want to change them, clone them with \`JSON.parse(JSON.stringify(FORM_FIELDS))\`.
91
-
92
- If you want to do it inside react component, you can use this pattern for the most effective cloning:
93
-
94
- \`\`\`
95
- const $fields = useValue$(useMemo(() => JSON.parse(JSON.stringify(FORM_FIELDS)), []))
96
- \`\`\`
97
-
98
- and then pass $fields to the Form component like this:
99
-
100
- \`\`\`
101
- <Form $fields={$fields} $value={$value} />
102
- \`\`\`
103
- `
104
- }