@things-factory/shell 10.1.40 → 10.1.46
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/dist-server/service/directive-transaction/transaction.js +11 -3
- package/dist-server/service/directive-transaction/transaction.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/after-commit.d.ts +15 -0
- package/dist-server/utils/after-commit.js +60 -0
- package/dist-server/utils/after-commit.js.map +1 -0
- package/dist-server/utils/index.d.ts +1 -0
- package/dist-server/utils/index.js +1 -0
- package/dist-server/utils/index.js.map +1 -1
- package/package.json +2 -2
- package/tests/after-commit.test.ts +200 -0
- package/tests/generator-list-page-template.test.ts +24 -0
- package/tests/undefined-custom-properties.test.ts +133 -0
- package/translations/en.json +8 -1
- package/translations/ja.json +8 -1
- package/translations/ko.json +8 -1
- package/translations/ms.json +8 -1
- package/translations/zh.json +9 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type AfterCommitStep = {
|
|
2
|
+
name: string;
|
|
3
|
+
run: () => unknown | Promise<unknown>;
|
|
4
|
+
};
|
|
5
|
+
export type AfterCommit = AfterCommitStep[];
|
|
6
|
+
/** Adds a step. With no list (outside a transaction) it returns false, and the caller does it at once. */
|
|
7
|
+
export declare function whenCommitted(afterCommit: AfterCommit | undefined, name: string, run: AfterCommitStep['run']): boolean;
|
|
8
|
+
/** Runs the steps in order. A failing one is reported by name and the rest still run. */
|
|
9
|
+
export declare function runAfterCommit(afterCommit: AfterCommit, report: (message: string, error: unknown) => void): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Opens the transaction, hands the body the list, and runs the list once committed. The directive and
|
|
12
|
+
* any code that opens a transaction by hand share it. transact has the shape of dataSource.transaction:
|
|
13
|
+
* if the body throws, it rolls back and throws.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runTransaction<T, TX>(transact: (work: (tx: TX) => Promise<T>) => Promise<T>, body: (tx: TX, afterCommit: AfterCommit) => Promise<T>, report: (message: string, error: unknown) => void): Promise<T>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* What to do after the commit - a list @transaction hangs on the request it opens (ruling 2026-09-24).
|
|
4
|
+
*
|
|
5
|
+
* -- Why --------------------------------------------------------------------
|
|
6
|
+
* Something that cannot be undone, such as deleting a file from storage, done inside the transaction
|
|
7
|
+
* outlives a commit that then fails: the rows come back and the file does not. An attachment row is
|
|
8
|
+
* left pointing at nothing.
|
|
9
|
+
*
|
|
10
|
+
* -- Shape ------------------------------------------------------------------
|
|
11
|
+
* The directive puts an empty list on context.state.afterCommit. Code inside does not do the
|
|
12
|
+
* irreversible thing at once; it adds it to the list with a name. The moment the transaction returns
|
|
13
|
+
* its value is the moment after the commit, and the list is run then, in order. On a rollback the
|
|
14
|
+
* transaction throws, the list is never reached, and it is dropped.
|
|
15
|
+
*
|
|
16
|
+
* -- A failure afterwards does not fail the request -------------------------
|
|
17
|
+
* The commit has happened; there is nothing to roll back. A failing step is logged with its name, so
|
|
18
|
+
* what was left behind can be found, and the next step runs. One orphaned file is less harm than an
|
|
19
|
+
* attachment row with no file.
|
|
20
|
+
*
|
|
21
|
+
* -- Where there is no list -------------------------------------------------
|
|
22
|
+
* Outside the directive - a call with no transaction - there is no list and nothing to roll back, so
|
|
23
|
+
* the caller does it at once. A transaction opened by hand (getDataSource().transaction(...) called
|
|
24
|
+
* directly) has no list either, and the same hole remains there; such code gets the list by opening
|
|
25
|
+
* its transaction with runTransaction.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.whenCommitted = whenCommitted;
|
|
29
|
+
exports.runAfterCommit = runAfterCommit;
|
|
30
|
+
exports.runTransaction = runTransaction;
|
|
31
|
+
/** Adds a step. With no list (outside a transaction) it returns false, and the caller does it at once. */
|
|
32
|
+
function whenCommitted(afterCommit, name, run) {
|
|
33
|
+
if (!Array.isArray(afterCommit))
|
|
34
|
+
return false;
|
|
35
|
+
afterCommit.push({ name, run });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** Runs the steps in order. A failing one is reported by name and the rest still run. */
|
|
39
|
+
async function runAfterCommit(afterCommit, report) {
|
|
40
|
+
for (const step of afterCommit) {
|
|
41
|
+
try {
|
|
42
|
+
await step.run();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
report(`after commit, "${step.name}" failed - left behind`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Opens the transaction, hands the body the list, and runs the list once committed. The directive and
|
|
51
|
+
* any code that opens a transaction by hand share it. transact has the shape of dataSource.transaction:
|
|
52
|
+
* if the body throws, it rolls back and throws.
|
|
53
|
+
*/
|
|
54
|
+
async function runTransaction(transact, body, report) {
|
|
55
|
+
const afterCommit = [];
|
|
56
|
+
const result = await transact(tx => body(tx, afterCommit));
|
|
57
|
+
await runAfterCommit(afterCommit, report);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=after-commit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"after-commit.js","sourceRoot":"","sources":["../../server/utils/after-commit.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;AAMH,sCAIC;AAGD,wCAWC;AAOD,wCAWC;AArCD,0GAA0G;AAC1G,SAAgB,aAAa,CAAC,WAAoC,EAAE,IAAY,EAAE,GAA2B;IAC3G,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC;QAAE,OAAO,KAAK,CAAA;IAC7C,WAAW,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAA;IAC/B,OAAO,IAAI,CAAA;AACb,CAAC;AAED,yFAAyF;AAClF,KAAK,UAAU,cAAc,CAClC,WAAwB,EACxB,MAAiD;IAEjD,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,GAAG,EAAE,CAAA;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,kBAAkB,IAAI,CAAC,IAAI,wBAAwB,EAAE,KAAK,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;GAIG;AACI,KAAK,UAAU,cAAc,CAClC,QAAsD,EACtD,IAAsD,EACtD,MAAiD;IAEjD,MAAM,WAAW,GAAgB,EAAE,CAAA;IACnC,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC,CAAA;IAE1D,MAAM,cAAc,CAAC,WAAW,EAAE,MAAM,CAAC,CAAA;IAEzC,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/*\n * What to do after the commit - a list @transaction hangs on the request it opens (ruling 2026-09-24).\n *\n * -- Why --------------------------------------------------------------------\n * Something that cannot be undone, such as deleting a file from storage, done inside the transaction\n * outlives a commit that then fails: the rows come back and the file does not. An attachment row is\n * left pointing at nothing.\n *\n * -- Shape ------------------------------------------------------------------\n * The directive puts an empty list on context.state.afterCommit. Code inside does not do the\n * irreversible thing at once; it adds it to the list with a name. The moment the transaction returns\n * its value is the moment after the commit, and the list is run then, in order. On a rollback the\n * transaction throws, the list is never reached, and it is dropped.\n *\n * -- A failure afterwards does not fail the request -------------------------\n * The commit has happened; there is nothing to roll back. A failing step is logged with its name, so\n * what was left behind can be found, and the next step runs. One orphaned file is less harm than an\n * attachment row with no file.\n *\n * -- Where there is no list -------------------------------------------------\n * Outside the directive - a call with no transaction - there is no list and nothing to roll back, so\n * the caller does it at once. A transaction opened by hand (getDataSource().transaction(...) called\n * directly) has no list either, and the same hole remains there; such code gets the list by opening\n * its transaction with runTransaction.\n */\n\nexport type AfterCommitStep = { name: string; run: () => unknown | Promise<unknown> }\nexport type AfterCommit = AfterCommitStep[]\n\n/** Adds a step. With no list (outside a transaction) it returns false, and the caller does it at once. */\nexport function whenCommitted(afterCommit: AfterCommit | undefined, name: string, run: AfterCommitStep['run']): boolean {\n if (!Array.isArray(afterCommit)) return false\n afterCommit.push({ name, run })\n return true\n}\n\n/** Runs the steps in order. A failing one is reported by name and the rest still run. */\nexport async function runAfterCommit(\n afterCommit: AfterCommit,\n report: (message: string, error: unknown) => void\n): Promise<void> {\n for (const step of afterCommit) {\n try {\n await step.run()\n } catch (error) {\n report(`after commit, \"${step.name}\" failed - left behind`, error)\n }\n }\n}\n\n/**\n * Opens the transaction, hands the body the list, and runs the list once committed. The directive and\n * any code that opens a transaction by hand share it. transact has the shape of dataSource.transaction:\n * if the body throws, it rolls back and throws.\n */\nexport async function runTransaction<T, TX>(\n transact: (work: (tx: TX) => Promise<T>) => Promise<T>,\n body: (tx: TX, afterCommit: AfterCommit) => Promise<T>,\n report: (message: string, error: unknown) => void\n): Promise<T> {\n const afterCommit: AfterCommit = []\n const result = await transact(tx => body(tx, afterCommit))\n\n await runAfterCommit(afterCommit, report)\n\n return result\n}\n"]}
|
|
@@ -13,4 +13,5 @@ tslib_1.__exportStar(require("./list-param-adjuster.js"), exports);
|
|
|
13
13
|
tslib_1.__exportStar(require("./get-times-for-period.js"), exports);
|
|
14
14
|
tslib_1.__exportStar(require("./headless-pool/index.js"), exports);
|
|
15
15
|
tslib_1.__exportStar(require("./subscribe-auth.js"), exports);
|
|
16
|
+
tslib_1.__exportStar(require("./after-commit.js"), exports);
|
|
16
17
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC;AACxC,8DAAmC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\nexport * from './subscribe-auth.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC;AACxC,8DAAmC;AACnC,4DAAiC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\nexport * from './subscribe-auth.js'\nexport * from './after-commit.js'\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/shell",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.46",
|
|
4
4
|
"description": "Core module for framework",
|
|
5
5
|
"bin": {
|
|
6
6
|
"things-factory": "bin/things-factory",
|
|
@@ -126,5 +126,5 @@
|
|
|
126
126
|
"pg": "^8.7.3",
|
|
127
127
|
"sqlite3": "^5.0.8"
|
|
128
128
|
},
|
|
129
|
-
"gitHead": "
|
|
129
|
+
"gitHead": "d3467cde550453f3348fed0ed6c79a12a6ba0db4"
|
|
130
130
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { graphql } from 'graphql'
|
|
2
|
+
import gql from 'graphql-tag'
|
|
3
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What waits for the commit (ADR-0091 decision 8).
|
|
7
|
+
*
|
|
8
|
+
* What this splits: after the commit against before it, a rollback against a commit, one failing step
|
|
9
|
+
* against the rest, and a call with a list against one without. The directive is driven through a real
|
|
10
|
+
* schema with the data source replaced by one that records when it commits.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const mockEvents: string[] = []
|
|
14
|
+
let mockFailCommit = false
|
|
15
|
+
|
|
16
|
+
jest.mock('../server/initializers/database', () => ({
|
|
17
|
+
getDataSource: () => ({
|
|
18
|
+
transaction: async (work: (tx: any) => Promise<any>) => {
|
|
19
|
+
const result = await work('tx')
|
|
20
|
+
if (mockFailCommit) throw new Error('commit failed')
|
|
21
|
+
mockEvents.push('commit')
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('@things-factory/env', () => ({ logger: { error: (message: string) => mockEvents.push(`logged: ${message}`) } }))
|
|
28
|
+
|
|
29
|
+
import { runAfterCommit, runTransaction, whenCommitted } from '../server/utils/after-commit'
|
|
30
|
+
import { transactionDirectiveResolver, transactionDirectiveTypeDefs } from '../server/service/directive-transaction/transaction'
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockEvents.length = 0
|
|
34
|
+
mockFailCommit = false
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('the list', () => {
|
|
38
|
+
it('without a list there is nothing to wait for: the caller is told to do it now', () => {
|
|
39
|
+
expect(whenCommitted(undefined, 'x', () => {})).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('with a list the step is added, not run', () => {
|
|
43
|
+
const list: any[] = []
|
|
44
|
+
let ran = false
|
|
45
|
+
|
|
46
|
+
expect(whenCommitted(list, 'x', () => (ran = true))).toBe(true)
|
|
47
|
+
expect(list.map(one => one.name)).toEqual(['x'])
|
|
48
|
+
expect(ran).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('steps run in order, and a failing one is reported by name while the rest still run', async () => {
|
|
52
|
+
const reported: string[] = []
|
|
53
|
+
|
|
54
|
+
await runAfterCommit(
|
|
55
|
+
[
|
|
56
|
+
{ name: 'first', run: () => mockEvents.push('first') },
|
|
57
|
+
{ name: 'breaks', run: () => Promise.reject(new Error('storage down')) },
|
|
58
|
+
{ name: 'third', run: () => mockEvents.push('third') }
|
|
59
|
+
],
|
|
60
|
+
message => reported.push(message)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(mockEvents).toEqual(['first', 'third'])
|
|
64
|
+
expect(reported).toEqual(['after commit, "breaks" failed - left behind'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('an empty list does nothing', async () => {
|
|
68
|
+
await runAfterCommit([], () => mockEvents.push('reported'))
|
|
69
|
+
expect(mockEvents).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('runTransaction', () => {
|
|
74
|
+
const transact = async (work: (tx: any) => Promise<any>) => {
|
|
75
|
+
const result = await work('tx')
|
|
76
|
+
if (mockFailCommit) throw new Error('commit failed')
|
|
77
|
+
mockEvents.push('commit')
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it('runs the list after the commit, not before', async () => {
|
|
82
|
+
await runTransaction(
|
|
83
|
+
transact,
|
|
84
|
+
async (_tx, afterCommit) => {
|
|
85
|
+
whenCommitted(afterCommit, 'file', () => mockEvents.push('file'))
|
|
86
|
+
mockEvents.push('body')
|
|
87
|
+
return 1
|
|
88
|
+
},
|
|
89
|
+
() => {}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(mockEvents).toEqual(['body', 'commit', 'file'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('a body that throws rolls back and the list is dropped', async () => {
|
|
96
|
+
await expect(
|
|
97
|
+
runTransaction(
|
|
98
|
+
transact,
|
|
99
|
+
async (_tx, afterCommit) => {
|
|
100
|
+
whenCommitted(afterCommit, 'file', () => mockEvents.push('file'))
|
|
101
|
+
throw new Error('refused')
|
|
102
|
+
},
|
|
103
|
+
() => {}
|
|
104
|
+
)
|
|
105
|
+
).rejects.toThrow('refused')
|
|
106
|
+
|
|
107
|
+
expect(mockEvents).not.toContain('file')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a commit that fails drops the list too', async () => {
|
|
111
|
+
mockFailCommit = true
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
runTransaction(
|
|
115
|
+
transact,
|
|
116
|
+
async (_tx, afterCommit) => {
|
|
117
|
+
whenCommitted(afterCommit, 'file', () => mockEvents.push('file'))
|
|
118
|
+
return 1
|
|
119
|
+
},
|
|
120
|
+
() => {}
|
|
121
|
+
)
|
|
122
|
+
).rejects.toThrow('commit failed')
|
|
123
|
+
|
|
124
|
+
expect(mockEvents).not.toContain('file')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a step that fails after the commit does not fail the request', async () => {
|
|
128
|
+
const result = await runTransaction(
|
|
129
|
+
transact,
|
|
130
|
+
async (_tx, afterCommit) => {
|
|
131
|
+
whenCommitted(afterCommit, 'file', () => Promise.reject(new Error('storage down')))
|
|
132
|
+
return 'done'
|
|
133
|
+
},
|
|
134
|
+
message => mockEvents.push(`reported: ${message}`)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
expect(result).toBe('done')
|
|
138
|
+
expect(mockEvents).toEqual(['commit', 'reported: after commit, "file" failed - left behind'])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('the @transaction directive', () => {
|
|
143
|
+
function schemaWith(resolve: (context: any) => any) {
|
|
144
|
+
const schema = makeExecutableSchema({
|
|
145
|
+
typeDefs: [
|
|
146
|
+
transactionDirectiveTypeDefs,
|
|
147
|
+
gql`
|
|
148
|
+
type Query {
|
|
149
|
+
ok: Boolean
|
|
150
|
+
}
|
|
151
|
+
type Mutation {
|
|
152
|
+
save: String @transaction
|
|
153
|
+
}
|
|
154
|
+
`
|
|
155
|
+
],
|
|
156
|
+
resolvers: { Mutation: { save: (_: any, __: any, context: any) => resolve(context) } }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return transactionDirectiveResolver(schema)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const context = () => ({ state: {}, t: (k: string) => k, app: { createContext: () => ({}) } })
|
|
163
|
+
|
|
164
|
+
it('hands the resolver a list and runs it after the commit', async () => {
|
|
165
|
+
const schema = schemaWith(context => {
|
|
166
|
+
whenCommitted(context.state.afterCommit, 'file', () => mockEvents.push('file'))
|
|
167
|
+
mockEvents.push('resolved')
|
|
168
|
+
return 'saved'
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const result = await graphql({ schema, source: 'mutation { save }', contextValue: context() })
|
|
172
|
+
|
|
173
|
+
expect(result.errors).toBeUndefined()
|
|
174
|
+
expect(mockEvents).toEqual(['resolved', 'commit', 'file'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('a resolver that throws leaves the list unrun', async () => {
|
|
178
|
+
const schema = schemaWith(context => {
|
|
179
|
+
whenCommitted(context.state.afterCommit, 'file', () => mockEvents.push('file'))
|
|
180
|
+
throw new Error('refused')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const result = await graphql({ schema, source: 'mutation { save }', contextValue: context() })
|
|
184
|
+
|
|
185
|
+
expect(result.errors?.[0]?.message).toBe('refused')
|
|
186
|
+
expect(mockEvents).not.toContain('file')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('a failing step after the commit is logged with the field it came from, and the mutation succeeds', async () => {
|
|
190
|
+
const schema = schemaWith(context => {
|
|
191
|
+
whenCommitted(context.state.afterCommit, 'file', () => Promise.reject(new Error('storage down')))
|
|
192
|
+
return 'saved'
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const result = await graphql({ schema, source: 'mutation { save }', contextValue: context() })
|
|
196
|
+
|
|
197
|
+
expect(result.data?.save).toBe('saved')
|
|
198
|
+
expect(mockEvents).toEqual(['commit', 'logged: after commit, "file" failed - left behind (field save)'])
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The generator's list page does not stamp colours nothing uses.
|
|
6
|
+
*
|
|
7
|
+
* The template set `--grid-record-emphasized-background-color: #8B0000` and a `#FF6B6B` text on every
|
|
8
|
+
* generated list page. Emphasis only happens through a grid `classifier`, and the template has none,
|
|
9
|
+
* so 64 of the 66 pages that carry the pair never emphasize a row - and the two that declare a
|
|
10
|
+
* classifier leave it empty [확인함 2026-09-24]. The pair was also a contrast failure waiting to
|
|
11
|
+
* happen (about 3.6:1 against WCAG AA's 4.5) and ignored the dark theme. A page that does emphasize
|
|
12
|
+
* rows chooses its colours from the theme where it needs them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TEMPLATE = path.resolve(
|
|
16
|
+
__dirname,
|
|
17
|
+
'../../generator/templates/service/client/pages/entity/{{name}}-list-page.ts.hbs'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
describe("the generator's list page template", () => {
|
|
21
|
+
it('★ stamps no emphasized-row colours', () => {
|
|
22
|
+
expect(readFileSync(TEMPLATE, 'utf-8')).not.toMatch(/--grid-record-emphasized-(background-)?color\s*:/)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A custom property read with no fallback must be defined somewhere.
|
|
6
|
+
*
|
|
7
|
+
* `var(--x)` with nothing defining `--x` is not an error anywhere: the declaration becomes invalid at
|
|
8
|
+
* computed-value time and the property falls back to its initial value. A hover colour turns into no
|
|
9
|
+
* hover, a padding into none, and every test stays green. Measured 2026-09-24 over operato's
|
|
10
|
+
* components and this repository's clients: 28 such reads, most of them typos - `--spacing-smail`,
|
|
11
|
+
* `--pading-default`, `--md-sys-on-color-secondary`, `--md-sys-color-on-s` - and Material names that
|
|
12
|
+
* do not exist (`--md-sys-color-primary-container-high`, `--md-sys-shape-corner-tiny`).
|
|
13
|
+
*
|
|
14
|
+
* Two scopes: this repository's clients from source, and the operato components as installed, since
|
|
15
|
+
* that is what an app runs. What defines a name: any declaration in this repository, in the installed
|
|
16
|
+
* @operato and @material/web packages, or a `setProperty('--x')`.
|
|
17
|
+
*
|
|
18
|
+
* ADR-0060 decision 1 (the machine catches a token nobody defines). The token inventory that found
|
|
19
|
+
* these is recorded in the ADR's status line.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(__dirname, '../../..')
|
|
23
|
+
const PACKAGES = path.join(ROOT, 'packages')
|
|
24
|
+
const MODULES = path.join(ROOT, 'node_modules')
|
|
25
|
+
|
|
26
|
+
function* files(dir: string, exts: RegExp): Generator<string> {
|
|
27
|
+
let entries: string[]
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(dir)
|
|
30
|
+
} catch {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (entry === 'node_modules' || entry === '.git' || entry === 'coverage') continue
|
|
35
|
+
const full = path.join(dir, entry)
|
|
36
|
+
let stat
|
|
37
|
+
try {
|
|
38
|
+
stat = statSync(full)
|
|
39
|
+
} catch {
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) yield* files(full, exts)
|
|
43
|
+
else if (exts.test(entry)) yield full
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFINES = /(--[a-z0-9-]+)\s*:/g
|
|
48
|
+
const SETS = /setProperty\(\s*['"`](--[a-z0-9-]+)/g
|
|
49
|
+
const BARE_READ = /var\(\s*(--[a-z0-9-]+)\s*\)/g
|
|
50
|
+
|
|
51
|
+
function definedNames(): Set<string> {
|
|
52
|
+
const names = new Set<string>()
|
|
53
|
+
const take = (file: string) => {
|
|
54
|
+
const text = readFileSync(file, 'utf-8')
|
|
55
|
+
for (const match of text.matchAll(DEFINES)) names.add(match[1])
|
|
56
|
+
for (const match of text.matchAll(SETS)) names.add(match[1])
|
|
57
|
+
}
|
|
58
|
+
for (const file of files(PACKAGES, /\.(ts|js|css|mjs|html)$/)) take(file)
|
|
59
|
+
for (const scope of ['@operato', '@material/web']) {
|
|
60
|
+
for (const file of files(path.join(MODULES, scope), /\.(js|css)$/)) take(file)
|
|
61
|
+
}
|
|
62
|
+
return names
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bareReadsOfUndefined(roots: string[], defined: Set<string>, skip: (file: string) => boolean): string[] {
|
|
66
|
+
const found = new Set<string>()
|
|
67
|
+
for (const root of roots) {
|
|
68
|
+
for (const file of files(root, /\.(ts|js|css)$/)) {
|
|
69
|
+
if (skip(file)) continue
|
|
70
|
+
for (const match of readFileSync(file, 'utf-8').matchAll(BARE_READ)) {
|
|
71
|
+
if (!defined.has(match[1])) found.add(match[1])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...found].sort()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* Hooks: names a component reads so that a product's theme can set them, and none of ours does.
|
|
80
|
+
* fmsim sets `--button-active-border` (and operato's `--grid-record-focused-color`) in its own theme
|
|
81
|
+
* files, operato-pms sets `--content-container-max-width`. The rest are undefined in every codebase we
|
|
82
|
+
* checked - they draw nothing today, and deciding what they should draw is a design change, not a
|
|
83
|
+
* fix. A name leaves this list when something defines it or the read goes.
|
|
84
|
+
*/
|
|
85
|
+
const HOOKS = [
|
|
86
|
+
'--button-active-border',
|
|
87
|
+
'--content-container-max-width',
|
|
88
|
+
'--form-input-width',
|
|
89
|
+
'--menu-tile-list-favorite-color',
|
|
90
|
+
'--menu-tile-list-item-margin'
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* The installed operato components, as of @operato 10.19-10.22 (process is not installed here). The fixed typos leave this list when the
|
|
95
|
+
* next @operato release is installed - the test goes red then, and the line is deleted with it.
|
|
96
|
+
*/
|
|
97
|
+
const OPERATO_KNOWN = [
|
|
98
|
+
'--button-radius',
|
|
99
|
+
'--calendar-monthly-color',
|
|
100
|
+
'--component-detail-border-color',
|
|
101
|
+
'--form-input-width',
|
|
102
|
+
'--grid-record-focused-color',
|
|
103
|
+
'--legend-background-color',
|
|
104
|
+
'--legend-border',
|
|
105
|
+
'--legend-border-radius',
|
|
106
|
+
'--legend-margin',
|
|
107
|
+
'--md-sys-color-on-primary-container-rgb',
|
|
108
|
+
'--md-sys-on-color-secondary',
|
|
109
|
+
'--md-sys-shape-corner-tiny',
|
|
110
|
+
'--md-sys-typescale-label-medium-weight',
|
|
111
|
+
'--opacity-light-dark-color',
|
|
112
|
+
'--pading-default',
|
|
113
|
+
'--record-view-edit-border',
|
|
114
|
+
'--record-view-grid-field-border',
|
|
115
|
+
'--spacing-smail'
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
describe('custom properties read with no fallback are defined', () => {
|
|
119
|
+
const defined = definedNames()
|
|
120
|
+
|
|
121
|
+
it("★ in this repository's clients", () => {
|
|
122
|
+
const clients = readdirSync(PACKAGES).map(pkg => path.join(PACKAGES, pkg, 'client'))
|
|
123
|
+
const skip = (file: string) => /\.test\.|\/themes\/|\/dist/.test(file)
|
|
124
|
+
|
|
125
|
+
expect(bareReadsOfUndefined(clients, defined, skip)).toEqual(HOOKS)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('★ in the operato components as installed', () => {
|
|
129
|
+
const skip = (file: string) => !/\/dist\//.test(file) || /\.(test|stories)\.|\/demo\/|\/test\//.test(file)
|
|
130
|
+
|
|
131
|
+
expect(bareReadsOfUndefined([path.join(MODULES, '@operato')], defined, skip)).toEqual(OPERATO_KNOWN)
|
|
132
|
+
})
|
|
133
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"button.add": "Add",
|
|
3
|
+
"button.back": "back",
|
|
3
4
|
"button.cancel": "cancel",
|
|
4
5
|
"button.close": "Close",
|
|
6
|
+
"button.collapse": "collapse",
|
|
5
7
|
"button.confirm": "confirm",
|
|
6
8
|
"button.copy": "copy",
|
|
7
9
|
"button.copy to draft": "copy to draft",
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
"button.edit": "edit",
|
|
13
15
|
"button.edit-contact": "edit contact",
|
|
14
16
|
"button.empty": "empty",
|
|
17
|
+
"button.expand": "expand",
|
|
15
18
|
"button.export": "export",
|
|
16
19
|
"button.refresh": "refresh",
|
|
17
20
|
"button.register": "register",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
"label.platform": "platform",
|
|
110
113
|
"label.please_select_a_country": "please select a country",
|
|
111
114
|
"label.please_select_a_platform": "please select a platform",
|
|
115
|
+
"label.resize": "resize",
|
|
112
116
|
"label.seconds": "seconds",
|
|
113
117
|
"label.state": "state",
|
|
114
118
|
"label.status": "Status",
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"text.data_updated_successfully": "data updated successfully",
|
|
128
132
|
"text.data_uploaded_successfully": "data uploaded successfully",
|
|
129
133
|
"text.draft": "draft",
|
|
134
|
+
"text.entity360-not-yet": "there is no 360 for this kind yet.",
|
|
130
135
|
"text.error": "error",
|
|
131
136
|
"text.existing_item_found": "existing item found",
|
|
132
137
|
"text.failed": "failed",
|
|
@@ -159,5 +164,7 @@
|
|
|
159
164
|
"title.lookup role": "lookup role",
|
|
160
165
|
"title.select_item": "select item",
|
|
161
166
|
"term.domain": "site",
|
|
162
|
-
"field.roles": "roles"
|
|
167
|
+
"field.roles": "roles",
|
|
168
|
+
"label.released": "released",
|
|
169
|
+
"label.import": "import"
|
|
163
170
|
}
|
package/translations/ja.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"button.add": "追加",
|
|
3
|
+
"button.back": "戻る",
|
|
3
4
|
"button.cancel": "キャンセル",
|
|
4
5
|
"button.close": "閉じる",
|
|
6
|
+
"button.collapse": "縮める",
|
|
5
7
|
"button.confirm": "確認",
|
|
6
8
|
"button.copy": "コピー",
|
|
7
9
|
"button.copy to draft": "ドラフトにコピー",
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
"button.edit": "修正",
|
|
13
15
|
"button.edit-contact": "連絡先を編集",
|
|
14
16
|
"button.empty": "空にする",
|
|
17
|
+
"button.expand": "広げる",
|
|
15
18
|
"button.export": "エクスポート",
|
|
16
19
|
"button.refresh": "リロード",
|
|
17
20
|
"button.register": "登録",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
"label.platform": "プラットフォーム",
|
|
110
113
|
"label.please_select_a_country": "国を選択してください",
|
|
111
114
|
"label.please_select_a_platform": "プラットフォームを選択してください",
|
|
115
|
+
"label.resize": "幅の調整",
|
|
112
116
|
"label.seconds": "秒",
|
|
113
117
|
"label.state": "状態",
|
|
114
118
|
"label.status": "状態",
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"text.data_updated_successfully": "資料の修正に成功しました.",
|
|
128
132
|
"text.data_uploaded_successfully": "資料が正常に作成されました.",
|
|
129
133
|
"text.draft": "ドラフト保存",
|
|
134
|
+
"text.entity360-not-yet": "この種類の 360 はまだありません。",
|
|
130
135
|
"text.error": "エラー",
|
|
131
136
|
"text.existing_item_found": "既にある項目です",
|
|
132
137
|
"text.failed": "失敗",
|
|
@@ -159,5 +164,7 @@
|
|
|
159
164
|
"title.lookup role": "ロール検索",
|
|
160
165
|
"title.select_item": "アイテム選択",
|
|
161
166
|
"term.domain": "事業場",
|
|
162
|
-
"field.roles": "ロール"
|
|
167
|
+
"field.roles": "ロール",
|
|
168
|
+
"label.released": "公開済み",
|
|
169
|
+
"label.import": "インポート"
|
|
163
170
|
}
|
package/translations/ko.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"button.add": "추가",
|
|
3
|
+
"button.back": "뒤로",
|
|
3
4
|
"button.cancel": "취소",
|
|
4
5
|
"button.close": "닫기",
|
|
6
|
+
"button.collapse": "줄이기",
|
|
5
7
|
"button.confirm": "확인",
|
|
6
8
|
"button.copy": "복사",
|
|
7
9
|
"button.copy to draft": "드래프트로 복사",
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
"button.edit": "수정",
|
|
13
15
|
"button.edit-contact": "연락처 편집",
|
|
14
16
|
"button.empty": "비우기",
|
|
17
|
+
"button.expand": "넓히기",
|
|
15
18
|
"button.export": "내보내기",
|
|
16
19
|
"button.refresh": "새로고침",
|
|
17
20
|
"button.register": "등록",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
"label.platform": "플랫폼",
|
|
110
113
|
"label.please_select_a_country": "국가를 선택하세요",
|
|
111
114
|
"label.please_select_a_platform": "플랫폼을 선택하세요",
|
|
115
|
+
"label.resize": "폭 조절",
|
|
112
116
|
"label.seconds": "초",
|
|
113
117
|
"label.state": "상태",
|
|
114
118
|
"label.status": "상태",
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"text.data_updated_successfully": "성공적으로 자료를 수정하였습니다.",
|
|
128
132
|
"text.data_uploaded_successfully": "성공적으로 자료를 업로드하였습니다.",
|
|
129
133
|
"text.draft": "초안저장",
|
|
134
|
+
"text.entity360-not-yet": "이 종류의 360 은 아직 없습니다.",
|
|
130
135
|
"text.error": "오류",
|
|
131
136
|
"text.existing_item_found": "이미 있는 항목입니다",
|
|
132
137
|
"text.failed": "실패",
|
|
@@ -159,5 +164,7 @@
|
|
|
159
164
|
"title.lookup role": "역할 검색",
|
|
160
165
|
"title.select_item": "아이템 선택",
|
|
161
166
|
"term.domain": "사업장",
|
|
162
|
-
"field.roles": "역할"
|
|
167
|
+
"field.roles": "역할",
|
|
168
|
+
"label.released": "발행됨",
|
|
169
|
+
"label.import": "가져오기"
|
|
163
170
|
}
|
package/translations/ms.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"button.add": "Tambah",
|
|
3
|
+
"button.back": "kembali",
|
|
3
4
|
"button.cancel": "batal",
|
|
4
5
|
"button.close": "tutup",
|
|
6
|
+
"button.collapse": "kecilkan",
|
|
5
7
|
"button.confirm": "sahkan",
|
|
6
8
|
"button.copy": "salin",
|
|
7
9
|
"button.copy to draft": "salin ke draf",
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
"button.edit": "Edit",
|
|
13
15
|
"button.edit-contact": "edit kenalan",
|
|
14
16
|
"button.empty": "kosongkan",
|
|
17
|
+
"button.expand": "besarkan",
|
|
15
18
|
"button.export": "Eksport",
|
|
16
19
|
"button.refresh": "Segar Semula",
|
|
17
20
|
"button.register": "daftar",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
"label.platform": "platform",
|
|
110
113
|
"label.please_select_a_country": "please select a country",
|
|
111
114
|
"label.please_select_a_platform": "please select a platform",
|
|
115
|
+
"label.resize": "ubah lebar",
|
|
112
116
|
"label.seconds": "saat",
|
|
113
117
|
"label.state": "negeri",
|
|
114
118
|
"label.status": "Status",
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"text.data_updated_successfully": "data berjaya dikemaskini",
|
|
128
132
|
"text.data_uploaded_successfully": "data uploaded successfully",
|
|
129
133
|
"text.draft": "draf",
|
|
134
|
+
"text.entity360-not-yet": "belum ada 360 untuk jenis ini.",
|
|
130
135
|
"text.error": "Ralat",
|
|
131
136
|
"text.existing_item_found": "item ini sudah ada",
|
|
132
137
|
"text.failed": "gagal",
|
|
@@ -159,5 +164,7 @@
|
|
|
159
164
|
"title.lookup role": "cari peranan",
|
|
160
165
|
"title.select_item": "pilih item",
|
|
161
166
|
"term.domain": "tapak",
|
|
162
|
-
"field.roles": "peranan"
|
|
167
|
+
"field.roles": "peranan",
|
|
168
|
+
"label.released": "diterbitkan",
|
|
169
|
+
"label.import": "import"
|
|
163
170
|
}
|