@things-factory/board-service 10.1.47 → 10.1.104

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-service",
3
- "version": "10.1.47",
3
+ "version": "10.1.104",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "client/index.js",
6
6
  "things-factory": true,
@@ -25,14 +25,14 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@hatiolab/things-scene": "^10.0.0",
28
- "@things-factory/auth-base": "^10.1.47",
28
+ "@things-factory/auth-base": "^10.1.79",
29
29
  "@things-factory/env": "^10.1.20",
30
- "@things-factory/fav-base": "^10.1.47",
31
- "@things-factory/font-base": "^10.1.47",
32
- "@things-factory/integration-base": "^10.1.47",
30
+ "@things-factory/fav-base": "^10.1.79",
31
+ "@things-factory/font-base": "^10.1.79",
32
+ "@things-factory/integration-base": "^10.1.104",
33
33
  "@things-factory/operato-license-checker": "^4.0.4",
34
34
  "content-disposition": "^0.5.3",
35
35
  "pdfjs-dist": "^5.0.375"
36
36
  },
37
- "gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
37
+ "gitHead": "0d05c7573220c6da2dec2654a4b86d7bd8e3c4fb"
38
38
  }
@@ -0,0 +1,83 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * The board save claim on postgres (ADR-0098, architect ruling 2026-09-27): postgres keeps microseconds
5
+ * while the editor holds milliseconds, and two saves that read the same moment arrive together. Concurrency
6
+ * is proven on postgres, where the row lock decides; sqlite's single writer cannot show it.
7
+ *
8
+ * Runs when TEST_POSTGRES_URL names an empty database; the schema is dropped and rebuilt.
9
+ */
10
+
11
+ const POSTGRES = process.env.TEST_POSTGRES_URL
12
+
13
+ require('@things-factory/env').config.set('ormconfig', { type: 'postgres' })
14
+
15
+ const { DataSource } = require('typeorm')
16
+ const { entityClosure } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
17
+ const DIST = join(__dirname, '..', 'dist-server')
18
+ const { Board } = require(join(DIST, 'service', 'board', 'board.js'))
19
+ const { claimUnchangedSince } = require(join(DIST, 'service', 'board', 'board-claim.js'))
20
+ const shell = require('@things-factory/shell')
21
+
22
+ const onPostgres = POSTGRES ? describe : describe.skip
23
+
24
+ onPostgres('board save claim (postgres, TEST_POSTGRES_URL)', () => {
25
+ let db: any
26
+ let here: any
27
+
28
+ beforeAll(async () => {
29
+ db = new DataSource({
30
+ type: 'postgres',
31
+ url: POSTGRES,
32
+ synchronize: true,
33
+ dropSchema: true,
34
+ namingStrategy: new shell.NamingStrategy(),
35
+ entities: entityClosure([Board]),
36
+ logging: false
37
+ })
38
+ await db.initialize()
39
+ shell.addDataSource('default', db)
40
+ here = await db.getRepository(shell.Domain).save({ name: 'here', subdomain: 'here' })
41
+ })
42
+
43
+ afterAll(async () => {
44
+ await db?.destroy()
45
+ })
46
+
47
+ const aBoard = async (name: string) => {
48
+ const saved = await db.getRepository(Board).save({ domain: here, name, model: '{"width":1,"height":1}' })
49
+ return db.getRepository(Board).findOneBy({ id: saved.id })
50
+ }
51
+
52
+ test('★ a stored time with microseconds is claimed by the millisecond the editor holds', async () => {
53
+ const board = await aBoard('micro')
54
+ await db.query(`UPDATE boards SET updated_at = '2026-09-27 01:02:03.456789' WHERE id = $1`, [board.id])
55
+ const read = (await db.getRepository(Board).findOneBy({ id: board.id })).updatedAt
56
+ expect(new Date(read).getMilliseconds()).toBe(456)
57
+
58
+ expect(await claimUnchangedSince(undefined, here.id, board.id, new Date(read))).toBe(true)
59
+ })
60
+
61
+ test('★ two saves that read the same moment: exactly one is claimed', async () => {
62
+ const board = await aBoard('race')
63
+ const read = new Date(board.updatedAt)
64
+
65
+ const attempt = () =>
66
+ db.transaction(async (tx: any) => {
67
+ const claimed = await claimUnchangedSince(tx, here.id, board.id, read)
68
+ /* Hold the lock a moment, as a real save does while it writes the model. */
69
+ await new Promise(resolve => setTimeout(resolve, 50))
70
+ return claimed
71
+ })
72
+
73
+ const results = await Promise.all([attempt(), attempt(), attempt()])
74
+ expect(results.filter(Boolean).length).toBe(1)
75
+ })
76
+
77
+ test('a save that read an older one is refused', async () => {
78
+ const board = await aBoard('older')
79
+ const read = new Date(board.updatedAt)
80
+ expect(await claimUnchangedSince(undefined, here.id, board.id, read)).toBe(true)
81
+ expect(await claimUnchangedSince(undefined, here.id, board.id, read)).toBe(false)
82
+ })
83
+ })
@@ -0,0 +1,79 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * A board is saved over only by someone who read its last save (ADR-0098, architect ruling 2026-09-27).
5
+ *
6
+ * The editor sends the updatedAt it read; the save is claimed in one conditional statement, compared to the
7
+ * millisecond. Here on sqlite through the real entity; the same claim is run on postgres, with two saves at
8
+ * once, in board-claim-postgres.test.ts.
9
+ */
10
+
11
+ const { createSqliteTestDataSource } = require(
12
+ join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs')
13
+ )
14
+ const DIST = join(__dirname, '..', 'dist-server')
15
+ const { Board } = require(join(DIST, 'service', 'board', 'board.js'))
16
+ const { claimUnchangedSince } = require(join(DIST, 'service', 'board', 'board-claim.js'))
17
+ const shell = require('@things-factory/shell')
18
+
19
+ let db: any
20
+ let here: any
21
+ let elsewhere: any
22
+
23
+ beforeAll(async () => {
24
+ db = createSqliteTestDataSource([Board])
25
+ await db.initialize()
26
+ shell.addDataSource('default', db)
27
+ here = await db.getRepository(shell.Domain).save({ name: 'here', subdomain: 'here' })
28
+ elsewhere = await db.getRepository(shell.Domain).save({ name: 'elsewhere', subdomain: 'elsewhere' })
29
+ })
30
+
31
+ afterAll(async () => {
32
+ await db?.destroy()
33
+ })
34
+
35
+ const aBoard = async (name: string) => {
36
+ const saved = await db.getRepository(Board).save({ domain: here, name, model: '{"width":1,"height":1}' })
37
+ return db.getRepository(Board).findOneBy({ id: saved.id })
38
+ }
39
+
40
+ test('★ the save that read the last save is claimed, and moves updatedAt on', async () => {
41
+ const board = await aBoard('read-last')
42
+ expect(await claimUnchangedSince(undefined, here.id, board.id, new Date(board.updatedAt))).toBe(true)
43
+
44
+ const after = await db.getRepository(Board).findOneBy({ id: board.id })
45
+ expect(new Date(after.updatedAt).getTime()).not.toBe(new Date(board.updatedAt).getTime())
46
+ })
47
+
48
+ test('★ a save that read an older one is refused', async () => {
49
+ const board = await aBoard('read-older')
50
+ const read = new Date(board.updatedAt)
51
+ await new Promise(resolve => setTimeout(resolve, 5))
52
+ expect(await claimUnchangedSince(undefined, here.id, board.id, read)).toBe(true)
53
+ expect(await claimUnchangedSince(undefined, here.id, board.id, read)).toBe(false)
54
+ })
55
+
56
+ test('the millisecond it read is enough - not the one before or after', async () => {
57
+ const board = await aBoard('window')
58
+ const at = new Date(board.updatedAt).getTime()
59
+ expect(await claimUnchangedSince(undefined, here.id, board.id, new Date(at - 1))).toBe(false)
60
+ expect(await claimUnchangedSince(undefined, here.id, board.id, new Date(at + 1))).toBe(false)
61
+ expect(await claimUnchangedSince(undefined, here.id, board.id, new Date(at))).toBe(true)
62
+ })
63
+
64
+ test('another site\'s board is never claimed', async () => {
65
+ const board = await aBoard('other-site')
66
+ expect(await claimUnchangedSince(undefined, elsewhere.id, board.id, new Date(board.updatedAt))).toBe(false)
67
+ })
68
+
69
+ test('★ updateBoard claims before it writes, refuses by name, and never stores the expectation', () => {
70
+ const { readFileSync } = require('fs')
71
+ const source = readFileSync(join(__dirname, '..', 'server', 'service', 'board', 'board-mutation.ts'), 'utf-8')
72
+ const update = source.slice(source.indexOf(' async updateBoard('), source.indexOf(' async releaseBoard('))
73
+ const claim = update.indexOf('claimUnchangedSince(tx, domain.id, id, new Date(patch.expectedUpdatedAt))')
74
+ expect(claim).toBeGreaterThan(0)
75
+ expect(claim).toBeLessThan(update.indexOf('repository.save('))
76
+ expect(update).toMatch(/throw new Refusal\(\s*'board-changed-since'/)
77
+ expect(update).toMatch(/const \{ groupId, expectedUpdatedAt, \.\.\.patched \} = patch/)
78
+ expect(source.slice(0, source.indexOf(' async updateBoard(')).slice(-400)).toMatch(/@UseMiddleware\(SpeakRefusals\)/)
79
+ })
@@ -8,5 +8,6 @@
8
8
  "error.group not found": "Group not found.",
9
9
  "error.group has boards": "Cannot delete a group that still contains {count} board(s). Move or remove the boards first.",
10
10
  "error.play group not found": "Play group not found.",
11
- "error.play group has boards": "Cannot delete a play group that still contains {count} board(s). Remove the boards first."
11
+ "error.play group has boards": "Cannot delete a play group that still contains {count} board(s). Remove the boards first.",
12
+ "error.board-changed-since": "this board was saved somewhere else after you opened it. open it again and then save."
12
13
  }
@@ -8,5 +8,6 @@
8
8
  "error.group has boards": "ボード{count}件が所属するグループは削除できません。先にボードを移動または削除してください。",
9
9
  "error.group not found": "グループが見つかりません。",
10
10
  "error.play group has boards": "ボード{count}件を含むプレイグループは削除できません。先にボードを削除してください。",
11
- "error.play group not found": "プレイグループが見つかりません。"
11
+ "error.play group not found": "プレイグループが見つかりません。",
12
+ "error.board-changed-since": "このボードを開いた後、別の場所で保存されました。開き直してから保存してください。"
12
13
  }
@@ -8,5 +8,6 @@
8
8
  "error.group not found": "그룹을 찾을 수 없습니다.",
9
9
  "error.group has boards": "보드 {count}개가 소속된 그룹은 삭제할 수 없습니다. 먼저 보드를 옮기거나 제거하세요.",
10
10
  "error.play group not found": "플레이 그룹을 찾을 수 없습니다.",
11
- "error.play group has boards": "보드 {count}개가 담긴 플레이 그룹은 삭제할 수 없습니다. 먼저 보드를 제거하세요."
11
+ "error.play group has boards": "보드 {count}개가 담긴 플레이 그룹은 삭제할 수 없습니다. 먼저 보드를 제거하세요.",
12
+ "error.board-changed-since": "이 보드를 연 뒤 다른 곳에서 저장했습니다. 다시 열어 보고 저장하세요."
12
13
  }
@@ -8,5 +8,6 @@
8
8
  "error.group has boards": "Kumpulan yang masih mengandungi {count} papan tidak boleh dipadam. Alihkan atau buang papan itu dahulu.",
9
9
  "error.group not found": "Kumpulan tidak dijumpai.",
10
10
  "error.play group has boards": "Kumpulan main yang masih mengandungi {count} papan tidak boleh dipadam. Buang papan itu dahulu.",
11
- "error.play group not found": "Kumpulan main tidak dijumpai."
11
+ "error.play group not found": "Kumpulan main tidak dijumpai.",
12
+ "error.board-changed-since": "papan ini telah disimpan di tempat lain selepas anda membukanya. buka semula dan kemudian simpan."
12
13
  }
@@ -8,5 +8,6 @@
8
8
  "error.group has boards": "仍有 {count} 个板块的组合无法删除。请先移动或删除这些板块。",
9
9
  "error.group not found": "找不到组合。",
10
10
  "error.play group has boards": "仍有 {count} 个板块的播放组合无法删除。请先删除这些板块。",
11
- "error.play group not found": "找不到播放组合。"
11
+ "error.play group not found": "找不到播放组合。",
12
+ "error.board-changed-since": "打开此看板后,它已在其他地方被保存。请重新打开后再保存。"
12
13
  }