@nocobase/lock-manager 2.1.0-beta.2 → 2.1.0-beta.21

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 ADDED
@@ -0,0 +1,99 @@
1
+ # NocoBase
2
+
3
+ <video width="100%" controls>
4
+ <source src="https://github.com/user-attachments/assets/4d11a87b-00e2-48f3-9bf7-389d21072d13" type="video/mp4">
5
+ </video>
6
+
7
+ <p align="center">
8
+ <a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
9
+ <a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
10
+ </p>
11
+
12
+ ## What is NocoBase
13
+
14
+ NocoBase is the most extensible AI-powered no-code platform.
15
+ Total control. Infinite extensibility. AI collaboration.
16
+ Enable your team to adapt quickly and cut costs dramatically.
17
+ No years of development. No millions wasted.
18
+ Deploy NocoBase in minutes — and take control of everything.
19
+
20
+ Homepage:
21
+ https://www.nocobase.com/
22
+
23
+ Online Demo:
24
+ https://demo.nocobase.com/new
25
+
26
+ Documents:
27
+ https://docs.nocobase.com/
28
+
29
+ Forum:
30
+ https://forum.nocobase.com/
31
+
32
+ Use Cases:
33
+ https://www.nocobase.com/en/blog/tags/customer-stories
34
+
35
+ ## Release Notes
36
+
37
+ Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary.
38
+
39
+ ## Distinctive features
40
+
41
+ ### 1. Data model-driven, not form/table–driven
42
+
43
+ Instead of being constrained by forms or tables, NocoBase adopts a data model–driven approach, separating data structure from user interface to unlock unlimited possibilities.
44
+
45
+ - UI and data structure are fully decoupled
46
+ - Multiple blocks and actions can be created for the same table or record in any quantity or form
47
+ - Supports the main database, external databases, and third-party APIs as data sources
48
+
49
+ ![model](https://static-docs.nocobase.com/model.png)
50
+
51
+ ### 2. AI employees, integrated into your business systems
52
+ Unlike standalone AI demos, NocoBase allows you to embed AI capabilities seamlessly into your interfaces, workflows, and data context, making AI truly useful in real business scenarios.
53
+
54
+ - Define AI employees for roles such as translator, analyst, researcher, or assistant
55
+ - Seamless AI–human collaboration in interfaces and workflows
56
+ - Ensure AI usage is secure, transparent, and customizable for your business needs
57
+
58
+ ![AI-employee](https://static-docs.nocobase.com/ai-employee-home.png)
59
+
60
+ ### 3. What you see is what you get, incredibly easy to use
61
+
62
+ While enabling the development of complex business systems, NocoBase keeps the experience simple and intuitive.
63
+
64
+ - One-click switch between usage mode and configuration mode
65
+ - Pages serve as a canvas to arrange blocks and actions, similar to Notion
66
+ - Configuration mode is designed for ordinary users, not just programmers
67
+
68
+ ![wysiwyg](https://static-docs.nocobase.com/wysiwyg.gif)
69
+
70
+ ### 4. Everything is a plugin, designed for extension
71
+ Adding more no-code features will never cover every business case. NocoBase is built for extension through its plugin-based microkernel architecture.
72
+
73
+ - All functionalities are plugins, similar to WordPress
74
+ - Plugins are ready to use upon installation
75
+ - Pages, blocks, actions, APIs, and data sources can all be extended through custom plugins
76
+
77
+ ![plugins](https://static-docs.nocobase.com/plugins.png)
78
+
79
+ ## Installation
80
+
81
+ NocoBase supports three installation methods:
82
+
83
+ - <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/docker-compose">Installing With Docker (👍Recommended)</a>
84
+
85
+ Suitable for no-code scenarios, no code to write. When upgrading, just download the latest image and reboot.
86
+
87
+ - <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/create-nocobase-app">Installing from create-nocobase-app CLI</a>
88
+
89
+ The business code of the project is completely independent and supports low-code development.
90
+
91
+ - <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/git-clone">Installing from Git source code</a>
92
+
93
+ If you want to experience the latest unreleased version, or want to participate in the contribution, you need to make changes and debug on the source code, it is recommended to choose this installation method, which requires a high level of development skills, and if the code has been updated, you can git pull the latest code.
94
+
95
+ ## How NocoBase works
96
+
97
+ <video width="100%" controls>
98
+ <source src="https://github.com/user-attachments/assets/8d183b44-9bb5-4792-b08f-bc08fe8dfaaf" type="video/mp4">
99
+ </video>
@@ -7,9 +7,20 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
  export type Releaser = () => void | Promise<void>;
10
+ /**
11
+ * A lock handle returned by {@link ILockAdapter.tryAcquire}.
12
+ *
13
+ * **Important**: the underlying mutex is already held when this object is
14
+ * returned. The caller MUST invoke one of `acquire()`, `runExclusive()`, or
15
+ * `release()` promptly; if none is called the lock will be held indefinitely.
16
+ *
17
+ * Use `release()` to abandon the lock without executing any work (e.g. when an
18
+ * early-return or error prevents the caller from proceeding).
19
+ */
10
20
  export interface ILock {
11
21
  acquire(ttl: number): Releaser | Promise<Releaser>;
12
22
  runExclusive<T>(fn: () => Promise<T>, ttl: number): Promise<T>;
23
+ release(): void | Promise<void>;
13
24
  }
14
25
  export interface ILockAdapter {
15
26
  connect(): Promise<void>;
@@ -41,6 +52,6 @@ export declare class LockManager {
41
52
  close(): Promise<void>;
42
53
  acquire(key: string, ttl?: number): Promise<Releaser>;
43
54
  runExclusive<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T>;
44
- tryAcquire(key: string): Promise<ILock>;
55
+ tryAcquire(key: string, timeout?: number): Promise<ILock>;
45
56
  }
46
57
  export default LockManager;
@@ -97,17 +97,69 @@ const _LocalLockAdapter = class _LocalLockAdapter {
97
97
  clearTimeout(timer);
98
98
  }
99
99
  }
100
- async tryAcquire(key) {
101
- const lock = this.getLock(key);
102
- if (lock.isLocked()) {
103
- throw new LockAcquireError("lock is locked");
100
+ async tryAcquire(key, timeout = 0) {
101
+ const mutex = this.getLock(key);
102
+ let preAcquiredRelease;
103
+ if (timeout === 0) {
104
+ if (mutex.isLocked()) {
105
+ throw new LockAcquireError("lock is locked");
106
+ }
107
+ preAcquiredRelease = await mutex.acquire();
108
+ } else {
109
+ try {
110
+ preAcquiredRelease = await (0, import_async_mutex.withTimeout)(mutex, timeout).acquire();
111
+ } catch (e) {
112
+ throw new LockAcquireError("lock acquire timed out", { cause: e });
113
+ }
104
114
  }
115
+ let preAcquiredConsumed = false;
116
+ const getRelease = /* @__PURE__ */ __name(async () => {
117
+ const rawRelease = !preAcquiredConsumed ? (preAcquiredConsumed = true, preAcquiredRelease) : await mutex.acquire();
118
+ let released = false;
119
+ return () => {
120
+ if (!released) {
121
+ released = true;
122
+ return rawRelease();
123
+ }
124
+ };
125
+ }, "getRelease");
105
126
  return {
127
+ release: /* @__PURE__ */ __name(async () => {
128
+ const release = await getRelease();
129
+ await release();
130
+ }, "release"),
106
131
  acquire: /* @__PURE__ */ __name(async (ttl) => {
107
- return this.acquire(key, ttl);
132
+ const release = await getRelease();
133
+ const timer = setTimeout(() => {
134
+ if (mutex.isLocked()) {
135
+ release();
136
+ }
137
+ }, ttl);
138
+ return () => {
139
+ release();
140
+ clearTimeout(timer);
141
+ };
108
142
  }, "acquire"),
109
143
  runExclusive: /* @__PURE__ */ __name(async (fn, ttl) => {
110
- return this.runExclusive(key, fn, ttl);
144
+ const release = await getRelease();
145
+ let timer;
146
+ try {
147
+ timer = setTimeout(() => {
148
+ if (mutex.isLocked()) {
149
+ release();
150
+ }
151
+ }, ttl);
152
+ return await fn();
153
+ } catch (e) {
154
+ if (e === import_async_mutex.E_CANCELED) {
155
+ throw new LockAbortError("Lock aborted", { cause: import_async_mutex.E_CANCELED });
156
+ } else {
157
+ throw e;
158
+ }
159
+ } finally {
160
+ clearTimeout(timer);
161
+ release();
162
+ }
111
163
  }, "runExclusive")
112
164
  };
113
165
  }
@@ -155,9 +207,9 @@ const _LockManager = class _LockManager {
155
207
  const client = await this.getAdapter();
156
208
  return client.runExclusive(key, fn, ttl);
157
209
  }
158
- async tryAcquire(key) {
210
+ async tryAcquire(key, timeout = 0) {
159
211
  const client = await this.getAdapter();
160
- return client.tryAcquire(key);
212
+ return client.tryAcquire(key, timeout);
161
213
  }
162
214
  };
163
215
  __name(_LockManager, "LockManager");
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@nocobase/lock-manager",
3
- "version": "2.1.0-beta.2",
3
+ "version": "2.1.0-beta.21",
4
4
  "main": "lib/index.js",
5
- "license": "AGPL-3.0",
5
+ "license": "Apache-2.0",
6
6
  "devDependencies": {
7
- "@nocobase/utils": "2.1.0-beta.2",
7
+ "@nocobase/utils": "2.1.0-beta.21",
8
8
  "async-mutex": "^0.5.0"
9
9
  },
10
- "gitHead": "d80433799fb4a8d59ded4d7eea114d585a137ea0"
10
+ "gitHead": "324bd82f33fca58e98711688a17ceb65c186b65e"
11
11
  }
@@ -165,5 +165,71 @@ describe('lock manager', () => {
165
165
  await r2();
166
166
  expect(order).toEqual([1, 2, 3, 4]);
167
167
  });
168
+
169
+ it('tryAcquire: only one concurrent caller wins (TOCTOU race)', async () => {
170
+ // Simulate two nodes calling tryAcquire at the same time (single-process,
171
+ // like createMockCluster). Only one should succeed; the other must throw.
172
+ const results: Array<'acquired' | 'skipped'> = [];
173
+ await Promise.all(
174
+ [0, 1].map(async () => {
175
+ try {
176
+ const lock = await lockManager.tryAcquire('race-test');
177
+ const release = await lock.acquire(1000);
178
+ results.push('acquired');
179
+ await release();
180
+ } catch (e) {
181
+ if (e instanceof LockAcquireError) {
182
+ results.push('skipped');
183
+ } else {
184
+ throw e;
185
+ }
186
+ }
187
+ }),
188
+ );
189
+ expect(results.filter((r) => r === 'acquired').length).toBe(1);
190
+ expect(results.filter((r) => r === 'skipped').length).toBe(1);
191
+ });
192
+
193
+ it('tryAcquire: waits up to timeout ms before throwing when lock is held', async () => {
194
+ // Acquire the lock manually so it is held during the tryAcquire call.
195
+ const holdRelease = await lockManager.acquire('wait-test');
196
+
197
+ // Release the lock after 50 ms — tryAcquire with timeout=1000 should
198
+ // succeed because the lock becomes available well within the window.
199
+ // Using a large margin (50ms release vs 1000ms timeout) to avoid flakiness
200
+ // on slow CI machines under load.
201
+ setTimeout(() => holdRelease(), 50);
202
+ const lock = await lockManager.tryAcquire('wait-test', 1000);
203
+ const release = await lock.acquire(1000);
204
+ await release();
205
+ });
206
+
207
+ it('tryAcquire: throws LockAcquireError when timeout expires before lock is released', async () => {
208
+ const holdRelease = await lockManager.acquire('timeout-test');
209
+ // Lock is held; tryAcquire with a very short timeout should fail.
210
+ await expect(lockManager.tryAcquire('timeout-test', 50)).rejects.toThrowError(LockAcquireError);
211
+ await holdRelease();
212
+ });
213
+
214
+ it('tryAcquire: subsequent call succeeds after previous lock is released', async () => {
215
+ const lock1 = await lockManager.tryAcquire('seq-test');
216
+ const release = await lock1.acquire(1000);
217
+ await release();
218
+ // After release, a second tryAcquire on the same key should succeed
219
+ const lock2 = await lockManager.tryAcquire('seq-test');
220
+ const release2 = await lock2.acquire(1000);
221
+ await release2();
222
+ });
223
+
224
+ it('tryAcquire: release() abandons the pre-acquired lock without executing work', async () => {
225
+ // Acquire the lock via tryAcquire, then immediately release it.
226
+ const lock = await lockManager.tryAcquire('cancel-test');
227
+ await lock.release();
228
+
229
+ // The lock should now be free; a subsequent tryAcquire must succeed.
230
+ const lock2 = await lockManager.tryAcquire('cancel-test');
231
+ const r = await lock2.acquire(1000);
232
+ await r();
233
+ });
168
234
  });
169
235
  });
@@ -8,13 +8,24 @@
8
8
  */
9
9
 
10
10
  import { Registry } from '@nocobase/utils';
11
- import { Mutex, MutexInterface, E_CANCELED } from 'async-mutex';
11
+ import { Mutex, MutexInterface, E_CANCELED, withTimeout } from 'async-mutex';
12
12
 
13
13
  export type Releaser = () => void | Promise<void>;
14
14
 
15
+ /**
16
+ * A lock handle returned by {@link ILockAdapter.tryAcquire}.
17
+ *
18
+ * **Important**: the underlying mutex is already held when this object is
19
+ * returned. The caller MUST invoke one of `acquire()`, `runExclusive()`, or
20
+ * `release()` promptly; if none is called the lock will be held indefinitely.
21
+ *
22
+ * Use `release()` to abandon the lock without executing any work (e.g. when an
23
+ * early-return or error prevents the caller from proceeding).
24
+ */
15
25
  export interface ILock {
16
26
  acquire(ttl: number): Releaser | Promise<Releaser>;
17
27
  runExclusive<T>(fn: () => Promise<T>, ttl: number): Promise<T>;
28
+ release(): void | Promise<void>;
18
29
  }
19
30
 
20
31
  export interface ILockAdapter {
@@ -87,17 +98,85 @@ class LocalLockAdapter implements ILockAdapter {
87
98
  }
88
99
  }
89
100
 
90
- async tryAcquire(key: string) {
91
- const lock = this.getLock(key);
92
- if (lock.isLocked()) {
93
- throw new LockAcquireError('lock is locked');
101
+ async tryAcquire(key: string, timeout = 0) {
102
+ const mutex = this.getLock(key);
103
+ let preAcquiredRelease: Releaser;
104
+
105
+ if (timeout === 0) {
106
+ // Non-blocking: throw immediately if the lock is already held.
107
+ // mutex.acquire() is called synchronously (before any await boundary) so
108
+ // that _locked=true is set atomically within the current JS execution
109
+ // slice, preventing TOCTOU races in single-process cluster simulations
110
+ // (e.g. tests using createMockCluster).
111
+ if (mutex.isLocked()) {
112
+ throw new LockAcquireError('lock is locked');
113
+ }
114
+ preAcquiredRelease = (await mutex.acquire()) as Releaser;
115
+ } else {
116
+ // Blocking with timeout: wait up to `timeout` ms for the lock, then
117
+ // throw. withTimeout() from async-mutex handles queue cleanup properly
118
+ // when the timeout fires before the lock is acquired.
119
+ try {
120
+ preAcquiredRelease = (await withTimeout(mutex, timeout).acquire()) as Releaser;
121
+ } catch (e) {
122
+ throw new LockAcquireError('lock acquire timed out', { cause: e });
123
+ }
94
124
  }
125
+
126
+ let preAcquiredConsumed = false;
127
+
128
+ const getRelease = async (): Promise<Releaser> => {
129
+ const rawRelease: Releaser = !preAcquiredConsumed
130
+ ? ((preAcquiredConsumed = true), preAcquiredRelease)
131
+ : ((await mutex.acquire()) as Releaser);
132
+ // Idempotency guard: prevents double-release when both the TTL auto-
133
+ // release timer and the caller-facing releaser (or finally block) fire.
134
+ let released = false;
135
+ return () => {
136
+ if (!released) {
137
+ released = true;
138
+ return (rawRelease as () => void | Promise<void>)();
139
+ }
140
+ };
141
+ };
142
+
95
143
  return {
96
- acquire: async (ttl) => {
97
- return this.acquire(key, ttl);
144
+ release: async (): Promise<void> => {
145
+ const release = await getRelease();
146
+ await release();
147
+ },
148
+ acquire: async (ttl: number): Promise<Releaser> => {
149
+ const release = await getRelease();
150
+ const timer: ReturnType<typeof setTimeout> = setTimeout(() => {
151
+ if (mutex.isLocked()) {
152
+ release();
153
+ }
154
+ }, ttl);
155
+ return () => {
156
+ release();
157
+ clearTimeout(timer);
158
+ };
98
159
  },
99
- runExclusive: async (fn: () => Promise<any>, ttl) => {
100
- return this.runExclusive(key, fn, ttl);
160
+ runExclusive: async <T>(fn: () => Promise<T>, ttl: number): Promise<T> => {
161
+ const release = await getRelease();
162
+ let timer: ReturnType<typeof setTimeout>;
163
+ try {
164
+ timer = setTimeout(() => {
165
+ if (mutex.isLocked()) {
166
+ release();
167
+ }
168
+ }, ttl);
169
+ return await fn();
170
+ } catch (e) {
171
+ if (e === E_CANCELED) {
172
+ throw new LockAbortError('Lock aborted', { cause: E_CANCELED });
173
+ } else {
174
+ throw e;
175
+ }
176
+ } finally {
177
+ clearTimeout(timer);
178
+ release();
179
+ }
101
180
  },
102
181
  };
103
182
  }
@@ -160,9 +239,9 @@ export class LockManager {
160
239
  return client.runExclusive(key, fn, ttl);
161
240
  }
162
241
 
163
- public async tryAcquire(key: string) {
242
+ public async tryAcquire(key: string, timeout = 0) {
164
243
  const client = await this.getAdapter();
165
- return client.tryAcquire(key);
244
+ return client.tryAcquire(key, timeout);
166
245
  }
167
246
  }
168
247