@things-factory/shell 10.1.39 → 10.1.44
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/bootstrap-hooks.d.ts +1 -0
- package/dist-server/bootstrap-hooks.js +28 -0
- package/dist-server/bootstrap-hooks.js.map +1 -1
- 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/undefined-custom-properties.test.ts +133 -0
- package/translations/en.json +3 -1
- package/translations/ja.json +3 -1
- package/translations/ko.json +3 -1
- package/translations/ms.json +3 -1
- package/translations/zh.json +4 -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.44",
|
|
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": "7c95c4e75e155e51c714409e865d96f317232c23"
|
|
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,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
package/translations/ja.json
CHANGED
package/translations/ko.json
CHANGED
package/translations/ms.json
CHANGED
package/translations/zh.json
CHANGED