Implement checked workspace integration contracts (WI-01)
This commit is contained in:
@@ -27,6 +27,7 @@ workspaceItem
|
|||||||
| sharedAttachmentDecl
|
| sharedAttachmentDecl
|
||||||
| conformanceDecl
|
| conformanceDecl
|
||||||
| constructorBindingDecl
|
| constructorBindingDecl
|
||||||
|
| presentationDecl
|
||||||
| typeAliasDecl
|
| typeAliasDecl
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -145,6 +146,15 @@ packageExport
|
|||||||
| packageConstructorExport
|
| packageConstructorExport
|
||||||
| packageQuery
|
| packageQuery
|
||||||
| packageQuerySpecialization
|
| packageQuerySpecialization
|
||||||
|
| componentDecl
|
||||||
|
;
|
||||||
|
|
||||||
|
componentDecl
|
||||||
|
: COMPONENT identifier ID stringLiteral SUBJECT interfaceType RUNTIME identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
presentationDecl
|
||||||
|
: PRESENTATION identifier ID stringLiteral FOR interfaceType USING identifier DOT identifier SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
packageQuery
|
packageQuery
|
||||||
@@ -471,6 +481,11 @@ identifier
|
|||||||
| RESULT_BYTES
|
| RESULT_BYTES
|
||||||
| RPC_CALLS
|
| RPC_CALLS
|
||||||
| DEADLINE_MS
|
| DEADLINE_MS
|
||||||
|
| COMPONENT
|
||||||
|
| PRESENTATION
|
||||||
|
| SUBJECT
|
||||||
|
| RUNTIME
|
||||||
|
| FOR
|
||||||
;
|
;
|
||||||
|
|
||||||
stringLiteral
|
stringLiteral
|
||||||
@@ -478,6 +493,11 @@ stringLiteral
|
|||||||
;
|
;
|
||||||
|
|
||||||
EDIT: 'edit';
|
EDIT: 'edit';
|
||||||
|
COMPONENT: 'component';
|
||||||
|
PRESENTATION: 'presentation';
|
||||||
|
SUBJECT: 'subject';
|
||||||
|
RUNTIME: 'runtime';
|
||||||
|
FOR: 'for';
|
||||||
CREATION: 'creation';
|
CREATION: 'creation';
|
||||||
INITIALIZE: 'initialize';
|
INITIALIZE: 'initialize';
|
||||||
WORKSPACE: 'workspace';
|
WORKSPACE: 'workspace';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
|||||||
import { compileRepositoryQuery } from "../query/compile.js";
|
import { compileRepositoryQuery } from "../query/compile.js";
|
||||||
import { checkQueryTemplate } from "../query/templates.js";
|
import { checkQueryTemplate } from "../query/templates.js";
|
||||||
import { readRepositorySource } from "./source-loader.js";
|
import { readRepositorySource } from "./source-loader.js";
|
||||||
|
import { parseControllerCatalog } from "../capability-model/controllers.js";
|
||||||
import { linkQueries } from "../query/link.js";
|
import { linkQueries } from "../query/link.js";
|
||||||
import {
|
import {
|
||||||
capabilityId,
|
capabilityId,
|
||||||
@@ -249,6 +250,14 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
compileRepositoryQuery(snapshot.directory, query, queryInterfaces),
|
compileRepositoryQuery(snapshot.directory, query, queryInterfaces),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
let controllerText: string | undefined;
|
||||||
|
try {
|
||||||
|
controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
if (controllerText !== undefined)
|
||||||
|
compiled.resource.revision.controllers = parseControllerCatalog(controllerText, queryInterfaces);
|
||||||
let catalogText: string | undefined;
|
let catalogText: string | undefined;
|
||||||
try {
|
try {
|
||||||
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export async function inspectAuthoringRepository(root: string, historyLimit = 10
|
|||||||
declarations: syntax
|
declarations: syntax
|
||||||
? [...walkSyntax(syntax.root)]
|
? [...walkSyntax(syntax.root)]
|
||||||
.filter((node) =>
|
.filter((node) =>
|
||||||
/^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind),
|
/^(?:interface|package|atom|state|edge|method|function|event|conformance|component|presentation)\w*Decl$/.test(
|
||||||
|
node.kind,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) }))
|
.map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) }))
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,289 +1,299 @@
|
|||||||
EDIT=1
|
EDIT=1
|
||||||
CREATION=2
|
COMPONENT=2
|
||||||
INITIALIZE=3
|
PRESENTATION=3
|
||||||
WORKSPACE=4
|
SUBJECT=4
|
||||||
EXECUTION=5
|
RUNTIME=5
|
||||||
SNAPSHOT=6
|
FOR=6
|
||||||
TRANSACTION=7
|
CREATION=7
|
||||||
REMOTE=8
|
INITIALIZE=8
|
||||||
TARGETS=9
|
WORKSPACE=9
|
||||||
PORTABLE=10
|
EXECUTION=10
|
||||||
REPLICA=11
|
SNAPSHOT=11
|
||||||
LOCAL=12
|
TRANSACTION=12
|
||||||
FROM=13
|
REMOTE=13
|
||||||
RESULT_BYTES=14
|
TARGETS=14
|
||||||
RPC_CALLS=15
|
PORTABLE=15
|
||||||
DEADLINE_MS=16
|
REPLICA=16
|
||||||
QUERY=17
|
LOCAL=17
|
||||||
REFLECTION=18
|
FROM=18
|
||||||
SPECIALIZE=19
|
RESULT_BYTES=19
|
||||||
ROOT=20
|
RPC_CALLS=20
|
||||||
DOCUMENT=21
|
DEADLINE_MS=21
|
||||||
FRAGMENTS=22
|
QUERY=22
|
||||||
VIEW=23
|
REFLECTION=23
|
||||||
MAX=24
|
SPECIALIZE=24
|
||||||
ALLOW=25
|
ROOT=25
|
||||||
POLL=26
|
DOCUMENT=26
|
||||||
QUERYABLE=27
|
FRAGMENTS=27
|
||||||
RPC=28
|
VIEW=28
|
||||||
QUERY_REASON=29
|
MAX=29
|
||||||
TYPE=30
|
ALLOW=30
|
||||||
OBJECT=31
|
POLL=31
|
||||||
STORABLE=32
|
QUERYABLE=32
|
||||||
IMPLEMENTS=33
|
RPC=33
|
||||||
REF=34
|
QUERY_REASON=34
|
||||||
FRAGMENT=35
|
TYPE=35
|
||||||
IMPORT=36
|
OBJECT=36
|
||||||
EXTERNAL=37
|
STORABLE=37
|
||||||
ATOM=38
|
IMPLEMENTS=38
|
||||||
INTERFACE=39
|
REF=39
|
||||||
INTERFACES=40
|
FRAGMENT=40
|
||||||
PACKAGE=41
|
IMPORT=41
|
||||||
VALUE=42
|
EXTERNAL=42
|
||||||
RELATION=43
|
ATOM=43
|
||||||
OPERATION=44
|
INTERFACE=44
|
||||||
FUNCTION=45
|
INTERFACES=45
|
||||||
CONSTRUCTOR=46
|
PACKAGE=46
|
||||||
CONSTRUCTS=47
|
VALUE=47
|
||||||
INPUT=48
|
RELATION=48
|
||||||
CONFORM=49
|
OPERATION=49
|
||||||
AS=50
|
FUNCTION=50
|
||||||
BIND=51
|
CONSTRUCTOR=51
|
||||||
STATIC=52
|
CONSTRUCTS=52
|
||||||
TO=53
|
INPUT=53
|
||||||
PRIVATE=54
|
CONFORM=54
|
||||||
SHARED=55
|
AS=55
|
||||||
STATE=56
|
BIND=56
|
||||||
EDGE=57
|
STATIC=57
|
||||||
PROJECTION=58
|
TO=58
|
||||||
WITH=59
|
PRIVATE=59
|
||||||
USING=60
|
SHARED=60
|
||||||
ACCEPT=61
|
STATE=61
|
||||||
VIA=62
|
EDGE=62
|
||||||
MATERIALIZE=63
|
PROJECTION=63
|
||||||
IF=64
|
WITH=64
|
||||||
ABSENT=65
|
USING=65
|
||||||
ON=66
|
ACCEPT=66
|
||||||
POLICY=67
|
VIA=67
|
||||||
DEFAULT=68
|
MATERIALIZE=68
|
||||||
SOURCE=69
|
IF=69
|
||||||
REPOSITORY=70
|
ABSENT=70
|
||||||
COMMIT=71
|
ON=71
|
||||||
REVISION=72
|
POLICY=72
|
||||||
SEMANTIC_MAJOR=73
|
DEFAULT=73
|
||||||
ON_DELETE=74
|
SOURCE=74
|
||||||
LIFECYCLE=75
|
REPOSITORY=75
|
||||||
RETAIN_OTHER=76
|
COMMIT=76
|
||||||
KEYED=77
|
REVISION=77
|
||||||
PUBLIC_TRAVERSAL=78
|
SEMANTIC_MAJOR=78
|
||||||
ID=79
|
ON_DELETE=79
|
||||||
DOC=80
|
LIFECYCLE=80
|
||||||
MODE=81
|
RETAIN_OTHER=81
|
||||||
EMITS=82
|
KEYED=82
|
||||||
RECEIVER=83
|
PUBLIC_TRAVERSAL=83
|
||||||
REQUIRES=84
|
ID=84
|
||||||
ANY=85
|
DOC=85
|
||||||
GET=86
|
MODE=86
|
||||||
SET=87
|
EMITS=87
|
||||||
WATCH=88
|
RECEIVER=88
|
||||||
START=89
|
REQUIRES=89
|
||||||
STOP=90
|
ANY=90
|
||||||
READ=91
|
GET=91
|
||||||
WRITE=92
|
SET=92
|
||||||
RESOLVE=93
|
WATCH=93
|
||||||
CONNECT=94
|
START=94
|
||||||
DISCONNECT=95
|
STOP=95
|
||||||
CALL=96
|
READ=96
|
||||||
WATCH_START=97
|
WRITE=97
|
||||||
WATCH_STOP=98
|
RESOLVE=98
|
||||||
SUBSCRIBE=99
|
CONNECT=99
|
||||||
UNSUBSCRIBE=100
|
DISCONNECT=100
|
||||||
REGISTER=101
|
CALL=101
|
||||||
CRDT=102
|
WATCH_START=102
|
||||||
OPTIONAL_ONE=103
|
WATCH_STOP=103
|
||||||
EXACTLY_ONE=104
|
SUBSCRIBE=104
|
||||||
MANY_UNIQUE=105
|
UNSUBSCRIBE=105
|
||||||
MANY=106
|
REGISTER=106
|
||||||
ORDERED=107
|
CRDT=107
|
||||||
UNIT=108
|
OPTIONAL_ONE=108
|
||||||
WATCH_HANDLE=109
|
EXACTLY_ONE=109
|
||||||
MESSAGE=110
|
MANY_UNIQUE=110
|
||||||
ATOM_REF=111
|
MANY=111
|
||||||
INTERFACE_REF=112
|
ORDERED=112
|
||||||
OPTIONAL=113
|
UNIT=113
|
||||||
LIST=114
|
WATCH_HANDLE=114
|
||||||
RECORD=115
|
MESSAGE=115
|
||||||
BOOL=116
|
ATOM_REF=116
|
||||||
BYTES=117
|
INTERFACE_REF=117
|
||||||
DOUBLE=118
|
OPTIONAL=118
|
||||||
INT32=119
|
LIST=119
|
||||||
INT64=120
|
RECORD=120
|
||||||
STRING=121
|
BOOL=121
|
||||||
UINT32=122
|
BYTES=122
|
||||||
UINT64=123
|
DOUBLE=123
|
||||||
TRUE=124
|
INT32=124
|
||||||
FALSE=125
|
INT64=125
|
||||||
NULL=126
|
STRING=126
|
||||||
ARROW=127
|
UINT32=127
|
||||||
COLON=128
|
UINT64=128
|
||||||
SEMI=129
|
TRUE=129
|
||||||
COMMA=130
|
FALSE=130
|
||||||
DOT=131
|
NULL=131
|
||||||
LBRACE=132
|
ARROW=132
|
||||||
RBRACE=133
|
COLON=133
|
||||||
LBRACK=134
|
SEMI=134
|
||||||
RBRACK=135
|
COMMA=135
|
||||||
LPAREN=136
|
DOT=136
|
||||||
RPAREN=137
|
LBRACE=137
|
||||||
LT=138
|
RBRACE=138
|
||||||
GT=139
|
LBRACK=139
|
||||||
AMP=140
|
RBRACK=140
|
||||||
EQUAL=141
|
LPAREN=141
|
||||||
INTEGER=142
|
RPAREN=142
|
||||||
JSON_NUMBER=143
|
LT=143
|
||||||
IDENTIFIER=144
|
GT=144
|
||||||
STRING_LITERAL=145
|
AMP=145
|
||||||
LINE_COMMENT=146
|
EQUAL=146
|
||||||
BLOCK_COMMENT=147
|
INTEGER=147
|
||||||
WS=148
|
JSON_NUMBER=148
|
||||||
|
IDENTIFIER=149
|
||||||
|
STRING_LITERAL=150
|
||||||
|
LINE_COMMENT=151
|
||||||
|
BLOCK_COMMENT=152
|
||||||
|
WS=153
|
||||||
'edit'=1
|
'edit'=1
|
||||||
'creation'=2
|
'component'=2
|
||||||
'initialize'=3
|
'presentation'=3
|
||||||
'workspace'=4
|
'subject'=4
|
||||||
'execution'=5
|
'runtime'=5
|
||||||
'snapshot'=6
|
'for'=6
|
||||||
'transaction'=7
|
'creation'=7
|
||||||
'remote'=8
|
'initialize'=8
|
||||||
'targets'=9
|
'workspace'=9
|
||||||
'portable'=10
|
'execution'=10
|
||||||
'replica'=11
|
'snapshot'=11
|
||||||
'local'=12
|
'transaction'=12
|
||||||
'from'=13
|
'remote'=13
|
||||||
'result-bytes'=14
|
'targets'=14
|
||||||
'rpc-calls'=15
|
'portable'=15
|
||||||
'deadline-ms'=16
|
'replica'=16
|
||||||
'query'=17
|
'local'=17
|
||||||
'reflection'=18
|
'from'=18
|
||||||
'specialize'=19
|
'result-bytes'=19
|
||||||
'root'=20
|
'rpc-calls'=20
|
||||||
'document'=21
|
'deadline-ms'=21
|
||||||
'fragments'=22
|
'query'=22
|
||||||
'view'=23
|
'reflection'=23
|
||||||
'max'=24
|
'specialize'=24
|
||||||
'allow'=25
|
'root'=25
|
||||||
'poll'=26
|
'document'=26
|
||||||
'queryable'=27
|
'fragments'=27
|
||||||
'rpc'=28
|
'view'=28
|
||||||
'query-reason'=29
|
'max'=29
|
||||||
'type'=30
|
'allow'=30
|
||||||
'object'=31
|
'poll'=31
|
||||||
'storable'=32
|
'queryable'=32
|
||||||
'implements'=33
|
'rpc'=33
|
||||||
'ref'=34
|
'query-reason'=34
|
||||||
'fragment'=35
|
'type'=35
|
||||||
'import'=36
|
'object'=36
|
||||||
'external'=37
|
'storable'=37
|
||||||
'atom'=38
|
'implements'=38
|
||||||
'interface'=39
|
'ref'=39
|
||||||
'interfaces'=40
|
'fragment'=40
|
||||||
'package'=41
|
'import'=41
|
||||||
'value'=42
|
'external'=42
|
||||||
'relation'=43
|
'atom'=43
|
||||||
'operation'=44
|
'interface'=44
|
||||||
'function'=45
|
'interfaces'=45
|
||||||
'constructor'=46
|
'package'=46
|
||||||
'constructs'=47
|
'value'=47
|
||||||
'input'=48
|
'relation'=48
|
||||||
'conform'=49
|
'operation'=49
|
||||||
'as'=50
|
'function'=50
|
||||||
'bind'=51
|
'constructor'=51
|
||||||
'static'=52
|
'constructs'=52
|
||||||
'to'=53
|
'input'=53
|
||||||
'private'=54
|
'conform'=54
|
||||||
'shared'=55
|
'as'=55
|
||||||
'state'=56
|
'bind'=56
|
||||||
'edge'=57
|
'static'=57
|
||||||
'projection'=58
|
'to'=58
|
||||||
'with'=59
|
'private'=59
|
||||||
'using'=60
|
'shared'=60
|
||||||
'accept'=61
|
'state'=61
|
||||||
'via'=62
|
'edge'=62
|
||||||
'materialize'=63
|
'projection'=63
|
||||||
'if'=64
|
'with'=64
|
||||||
'absent'=65
|
'using'=65
|
||||||
'on'=66
|
'accept'=66
|
||||||
'policy'=67
|
'via'=67
|
||||||
'default'=68
|
'materialize'=68
|
||||||
'source'=69
|
'if'=69
|
||||||
'repository'=70
|
'absent'=70
|
||||||
'commit'=71
|
'on'=71
|
||||||
'revision'=72
|
'policy'=72
|
||||||
'semantic-major'=73
|
'default'=73
|
||||||
'on-delete'=74
|
'source'=74
|
||||||
'lifecycle'=75
|
'repository'=75
|
||||||
'retain-other'=76
|
'commit'=76
|
||||||
'keyed'=77
|
'revision'=77
|
||||||
'public-traversal'=78
|
'semantic-major'=78
|
||||||
'id'=79
|
'on-delete'=79
|
||||||
'doc'=80
|
'lifecycle'=80
|
||||||
'mode'=81
|
'retain-other'=81
|
||||||
'emits'=82
|
'keyed'=82
|
||||||
'receiver'=83
|
'public-traversal'=83
|
||||||
'requires'=84
|
'id'=84
|
||||||
'any'=85
|
'doc'=85
|
||||||
'get'=86
|
'mode'=86
|
||||||
'set'=87
|
'emits'=87
|
||||||
'watch'=88
|
'receiver'=88
|
||||||
'start'=89
|
'requires'=89
|
||||||
'stop'=90
|
'any'=90
|
||||||
'read'=91
|
'get'=91
|
||||||
'write'=92
|
'set'=92
|
||||||
'resolve'=93
|
'watch'=93
|
||||||
'connect'=94
|
'start'=94
|
||||||
'disconnect'=95
|
'stop'=95
|
||||||
'call'=96
|
'read'=96
|
||||||
'watch-start'=97
|
'write'=97
|
||||||
'watch-stop'=98
|
'resolve'=98
|
||||||
'subscribe'=99
|
'connect'=99
|
||||||
'unsubscribe'=100
|
'disconnect'=100
|
||||||
'register'=101
|
'call'=101
|
||||||
'crdt'=102
|
'watch-start'=102
|
||||||
'optional-one'=103
|
'watch-stop'=103
|
||||||
'exactly-one'=104
|
'subscribe'=104
|
||||||
'many-unique'=105
|
'unsubscribe'=105
|
||||||
'many'=106
|
'register'=106
|
||||||
'ordered'=107
|
'crdt'=107
|
||||||
'unit'=108
|
'optional-one'=108
|
||||||
'watch-handle'=109
|
'exactly-one'=109
|
||||||
'message'=110
|
'many-unique'=110
|
||||||
'atom-ref'=111
|
'many'=111
|
||||||
'interface-ref'=112
|
'ordered'=112
|
||||||
'optional'=113
|
'unit'=113
|
||||||
'list'=114
|
'watch-handle'=114
|
||||||
'record'=115
|
'message'=115
|
||||||
'bool'=116
|
'atom-ref'=116
|
||||||
'bytes'=117
|
'interface-ref'=117
|
||||||
'double'=118
|
'optional'=118
|
||||||
'int32'=119
|
'list'=119
|
||||||
'int64'=120
|
'record'=120
|
||||||
'string'=121
|
'bool'=121
|
||||||
'uint32'=122
|
'bytes'=122
|
||||||
'uint64'=123
|
'double'=123
|
||||||
'true'=124
|
'int32'=124
|
||||||
'false'=125
|
'int64'=125
|
||||||
'null'=126
|
'string'=126
|
||||||
'->'=127
|
'uint32'=127
|
||||||
':'=128
|
'uint64'=128
|
||||||
';'=129
|
'true'=129
|
||||||
','=130
|
'false'=130
|
||||||
'.'=131
|
'null'=131
|
||||||
'{'=132
|
'->'=132
|
||||||
'}'=133
|
':'=133
|
||||||
'['=134
|
';'=134
|
||||||
']'=135
|
','=135
|
||||||
'('=136
|
'.'=136
|
||||||
')'=137
|
'{'=137
|
||||||
'<'=138
|
'}'=138
|
||||||
'>'=139
|
'['=139
|
||||||
'&'=140
|
']'=140
|
||||||
'='=141
|
'('=141
|
||||||
|
')'=142
|
||||||
|
'<'=143
|
||||||
|
'>'=144
|
||||||
|
'&'=145
|
||||||
|
'='=146
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,289 +1,299 @@
|
|||||||
EDIT=1
|
EDIT=1
|
||||||
CREATION=2
|
COMPONENT=2
|
||||||
INITIALIZE=3
|
PRESENTATION=3
|
||||||
WORKSPACE=4
|
SUBJECT=4
|
||||||
EXECUTION=5
|
RUNTIME=5
|
||||||
SNAPSHOT=6
|
FOR=6
|
||||||
TRANSACTION=7
|
CREATION=7
|
||||||
REMOTE=8
|
INITIALIZE=8
|
||||||
TARGETS=9
|
WORKSPACE=9
|
||||||
PORTABLE=10
|
EXECUTION=10
|
||||||
REPLICA=11
|
SNAPSHOT=11
|
||||||
LOCAL=12
|
TRANSACTION=12
|
||||||
FROM=13
|
REMOTE=13
|
||||||
RESULT_BYTES=14
|
TARGETS=14
|
||||||
RPC_CALLS=15
|
PORTABLE=15
|
||||||
DEADLINE_MS=16
|
REPLICA=16
|
||||||
QUERY=17
|
LOCAL=17
|
||||||
REFLECTION=18
|
FROM=18
|
||||||
SPECIALIZE=19
|
RESULT_BYTES=19
|
||||||
ROOT=20
|
RPC_CALLS=20
|
||||||
DOCUMENT=21
|
DEADLINE_MS=21
|
||||||
FRAGMENTS=22
|
QUERY=22
|
||||||
VIEW=23
|
REFLECTION=23
|
||||||
MAX=24
|
SPECIALIZE=24
|
||||||
ALLOW=25
|
ROOT=25
|
||||||
POLL=26
|
DOCUMENT=26
|
||||||
QUERYABLE=27
|
FRAGMENTS=27
|
||||||
RPC=28
|
VIEW=28
|
||||||
QUERY_REASON=29
|
MAX=29
|
||||||
TYPE=30
|
ALLOW=30
|
||||||
OBJECT=31
|
POLL=31
|
||||||
STORABLE=32
|
QUERYABLE=32
|
||||||
IMPLEMENTS=33
|
RPC=33
|
||||||
REF=34
|
QUERY_REASON=34
|
||||||
FRAGMENT=35
|
TYPE=35
|
||||||
IMPORT=36
|
OBJECT=36
|
||||||
EXTERNAL=37
|
STORABLE=37
|
||||||
ATOM=38
|
IMPLEMENTS=38
|
||||||
INTERFACE=39
|
REF=39
|
||||||
INTERFACES=40
|
FRAGMENT=40
|
||||||
PACKAGE=41
|
IMPORT=41
|
||||||
VALUE=42
|
EXTERNAL=42
|
||||||
RELATION=43
|
ATOM=43
|
||||||
OPERATION=44
|
INTERFACE=44
|
||||||
FUNCTION=45
|
INTERFACES=45
|
||||||
CONSTRUCTOR=46
|
PACKAGE=46
|
||||||
CONSTRUCTS=47
|
VALUE=47
|
||||||
INPUT=48
|
RELATION=48
|
||||||
CONFORM=49
|
OPERATION=49
|
||||||
AS=50
|
FUNCTION=50
|
||||||
BIND=51
|
CONSTRUCTOR=51
|
||||||
STATIC=52
|
CONSTRUCTS=52
|
||||||
TO=53
|
INPUT=53
|
||||||
PRIVATE=54
|
CONFORM=54
|
||||||
SHARED=55
|
AS=55
|
||||||
STATE=56
|
BIND=56
|
||||||
EDGE=57
|
STATIC=57
|
||||||
PROJECTION=58
|
TO=58
|
||||||
WITH=59
|
PRIVATE=59
|
||||||
USING=60
|
SHARED=60
|
||||||
ACCEPT=61
|
STATE=61
|
||||||
VIA=62
|
EDGE=62
|
||||||
MATERIALIZE=63
|
PROJECTION=63
|
||||||
IF=64
|
WITH=64
|
||||||
ABSENT=65
|
USING=65
|
||||||
ON=66
|
ACCEPT=66
|
||||||
POLICY=67
|
VIA=67
|
||||||
DEFAULT=68
|
MATERIALIZE=68
|
||||||
SOURCE=69
|
IF=69
|
||||||
REPOSITORY=70
|
ABSENT=70
|
||||||
COMMIT=71
|
ON=71
|
||||||
REVISION=72
|
POLICY=72
|
||||||
SEMANTIC_MAJOR=73
|
DEFAULT=73
|
||||||
ON_DELETE=74
|
SOURCE=74
|
||||||
LIFECYCLE=75
|
REPOSITORY=75
|
||||||
RETAIN_OTHER=76
|
COMMIT=76
|
||||||
KEYED=77
|
REVISION=77
|
||||||
PUBLIC_TRAVERSAL=78
|
SEMANTIC_MAJOR=78
|
||||||
ID=79
|
ON_DELETE=79
|
||||||
DOC=80
|
LIFECYCLE=80
|
||||||
MODE=81
|
RETAIN_OTHER=81
|
||||||
EMITS=82
|
KEYED=82
|
||||||
RECEIVER=83
|
PUBLIC_TRAVERSAL=83
|
||||||
REQUIRES=84
|
ID=84
|
||||||
ANY=85
|
DOC=85
|
||||||
GET=86
|
MODE=86
|
||||||
SET=87
|
EMITS=87
|
||||||
WATCH=88
|
RECEIVER=88
|
||||||
START=89
|
REQUIRES=89
|
||||||
STOP=90
|
ANY=90
|
||||||
READ=91
|
GET=91
|
||||||
WRITE=92
|
SET=92
|
||||||
RESOLVE=93
|
WATCH=93
|
||||||
CONNECT=94
|
START=94
|
||||||
DISCONNECT=95
|
STOP=95
|
||||||
CALL=96
|
READ=96
|
||||||
WATCH_START=97
|
WRITE=97
|
||||||
WATCH_STOP=98
|
RESOLVE=98
|
||||||
SUBSCRIBE=99
|
CONNECT=99
|
||||||
UNSUBSCRIBE=100
|
DISCONNECT=100
|
||||||
REGISTER=101
|
CALL=101
|
||||||
CRDT=102
|
WATCH_START=102
|
||||||
OPTIONAL_ONE=103
|
WATCH_STOP=103
|
||||||
EXACTLY_ONE=104
|
SUBSCRIBE=104
|
||||||
MANY_UNIQUE=105
|
UNSUBSCRIBE=105
|
||||||
MANY=106
|
REGISTER=106
|
||||||
ORDERED=107
|
CRDT=107
|
||||||
UNIT=108
|
OPTIONAL_ONE=108
|
||||||
WATCH_HANDLE=109
|
EXACTLY_ONE=109
|
||||||
MESSAGE=110
|
MANY_UNIQUE=110
|
||||||
ATOM_REF=111
|
MANY=111
|
||||||
INTERFACE_REF=112
|
ORDERED=112
|
||||||
OPTIONAL=113
|
UNIT=113
|
||||||
LIST=114
|
WATCH_HANDLE=114
|
||||||
RECORD=115
|
MESSAGE=115
|
||||||
BOOL=116
|
ATOM_REF=116
|
||||||
BYTES=117
|
INTERFACE_REF=117
|
||||||
DOUBLE=118
|
OPTIONAL=118
|
||||||
INT32=119
|
LIST=119
|
||||||
INT64=120
|
RECORD=120
|
||||||
STRING=121
|
BOOL=121
|
||||||
UINT32=122
|
BYTES=122
|
||||||
UINT64=123
|
DOUBLE=123
|
||||||
TRUE=124
|
INT32=124
|
||||||
FALSE=125
|
INT64=125
|
||||||
NULL=126
|
STRING=126
|
||||||
ARROW=127
|
UINT32=127
|
||||||
COLON=128
|
UINT64=128
|
||||||
SEMI=129
|
TRUE=129
|
||||||
COMMA=130
|
FALSE=130
|
||||||
DOT=131
|
NULL=131
|
||||||
LBRACE=132
|
ARROW=132
|
||||||
RBRACE=133
|
COLON=133
|
||||||
LBRACK=134
|
SEMI=134
|
||||||
RBRACK=135
|
COMMA=135
|
||||||
LPAREN=136
|
DOT=136
|
||||||
RPAREN=137
|
LBRACE=137
|
||||||
LT=138
|
RBRACE=138
|
||||||
GT=139
|
LBRACK=139
|
||||||
AMP=140
|
RBRACK=140
|
||||||
EQUAL=141
|
LPAREN=141
|
||||||
INTEGER=142
|
RPAREN=142
|
||||||
JSON_NUMBER=143
|
LT=143
|
||||||
IDENTIFIER=144
|
GT=144
|
||||||
STRING_LITERAL=145
|
AMP=145
|
||||||
LINE_COMMENT=146
|
EQUAL=146
|
||||||
BLOCK_COMMENT=147
|
INTEGER=147
|
||||||
WS=148
|
JSON_NUMBER=148
|
||||||
|
IDENTIFIER=149
|
||||||
|
STRING_LITERAL=150
|
||||||
|
LINE_COMMENT=151
|
||||||
|
BLOCK_COMMENT=152
|
||||||
|
WS=153
|
||||||
'edit'=1
|
'edit'=1
|
||||||
'creation'=2
|
'component'=2
|
||||||
'initialize'=3
|
'presentation'=3
|
||||||
'workspace'=4
|
'subject'=4
|
||||||
'execution'=5
|
'runtime'=5
|
||||||
'snapshot'=6
|
'for'=6
|
||||||
'transaction'=7
|
'creation'=7
|
||||||
'remote'=8
|
'initialize'=8
|
||||||
'targets'=9
|
'workspace'=9
|
||||||
'portable'=10
|
'execution'=10
|
||||||
'replica'=11
|
'snapshot'=11
|
||||||
'local'=12
|
'transaction'=12
|
||||||
'from'=13
|
'remote'=13
|
||||||
'result-bytes'=14
|
'targets'=14
|
||||||
'rpc-calls'=15
|
'portable'=15
|
||||||
'deadline-ms'=16
|
'replica'=16
|
||||||
'query'=17
|
'local'=17
|
||||||
'reflection'=18
|
'from'=18
|
||||||
'specialize'=19
|
'result-bytes'=19
|
||||||
'root'=20
|
'rpc-calls'=20
|
||||||
'document'=21
|
'deadline-ms'=21
|
||||||
'fragments'=22
|
'query'=22
|
||||||
'view'=23
|
'reflection'=23
|
||||||
'max'=24
|
'specialize'=24
|
||||||
'allow'=25
|
'root'=25
|
||||||
'poll'=26
|
'document'=26
|
||||||
'queryable'=27
|
'fragments'=27
|
||||||
'rpc'=28
|
'view'=28
|
||||||
'query-reason'=29
|
'max'=29
|
||||||
'type'=30
|
'allow'=30
|
||||||
'object'=31
|
'poll'=31
|
||||||
'storable'=32
|
'queryable'=32
|
||||||
'implements'=33
|
'rpc'=33
|
||||||
'ref'=34
|
'query-reason'=34
|
||||||
'fragment'=35
|
'type'=35
|
||||||
'import'=36
|
'object'=36
|
||||||
'external'=37
|
'storable'=37
|
||||||
'atom'=38
|
'implements'=38
|
||||||
'interface'=39
|
'ref'=39
|
||||||
'interfaces'=40
|
'fragment'=40
|
||||||
'package'=41
|
'import'=41
|
||||||
'value'=42
|
'external'=42
|
||||||
'relation'=43
|
'atom'=43
|
||||||
'operation'=44
|
'interface'=44
|
||||||
'function'=45
|
'interfaces'=45
|
||||||
'constructor'=46
|
'package'=46
|
||||||
'constructs'=47
|
'value'=47
|
||||||
'input'=48
|
'relation'=48
|
||||||
'conform'=49
|
'operation'=49
|
||||||
'as'=50
|
'function'=50
|
||||||
'bind'=51
|
'constructor'=51
|
||||||
'static'=52
|
'constructs'=52
|
||||||
'to'=53
|
'input'=53
|
||||||
'private'=54
|
'conform'=54
|
||||||
'shared'=55
|
'as'=55
|
||||||
'state'=56
|
'bind'=56
|
||||||
'edge'=57
|
'static'=57
|
||||||
'projection'=58
|
'to'=58
|
||||||
'with'=59
|
'private'=59
|
||||||
'using'=60
|
'shared'=60
|
||||||
'accept'=61
|
'state'=61
|
||||||
'via'=62
|
'edge'=62
|
||||||
'materialize'=63
|
'projection'=63
|
||||||
'if'=64
|
'with'=64
|
||||||
'absent'=65
|
'using'=65
|
||||||
'on'=66
|
'accept'=66
|
||||||
'policy'=67
|
'via'=67
|
||||||
'default'=68
|
'materialize'=68
|
||||||
'source'=69
|
'if'=69
|
||||||
'repository'=70
|
'absent'=70
|
||||||
'commit'=71
|
'on'=71
|
||||||
'revision'=72
|
'policy'=72
|
||||||
'semantic-major'=73
|
'default'=73
|
||||||
'on-delete'=74
|
'source'=74
|
||||||
'lifecycle'=75
|
'repository'=75
|
||||||
'retain-other'=76
|
'commit'=76
|
||||||
'keyed'=77
|
'revision'=77
|
||||||
'public-traversal'=78
|
'semantic-major'=78
|
||||||
'id'=79
|
'on-delete'=79
|
||||||
'doc'=80
|
'lifecycle'=80
|
||||||
'mode'=81
|
'retain-other'=81
|
||||||
'emits'=82
|
'keyed'=82
|
||||||
'receiver'=83
|
'public-traversal'=83
|
||||||
'requires'=84
|
'id'=84
|
||||||
'any'=85
|
'doc'=85
|
||||||
'get'=86
|
'mode'=86
|
||||||
'set'=87
|
'emits'=87
|
||||||
'watch'=88
|
'receiver'=88
|
||||||
'start'=89
|
'requires'=89
|
||||||
'stop'=90
|
'any'=90
|
||||||
'read'=91
|
'get'=91
|
||||||
'write'=92
|
'set'=92
|
||||||
'resolve'=93
|
'watch'=93
|
||||||
'connect'=94
|
'start'=94
|
||||||
'disconnect'=95
|
'stop'=95
|
||||||
'call'=96
|
'read'=96
|
||||||
'watch-start'=97
|
'write'=97
|
||||||
'watch-stop'=98
|
'resolve'=98
|
||||||
'subscribe'=99
|
'connect'=99
|
||||||
'unsubscribe'=100
|
'disconnect'=100
|
||||||
'register'=101
|
'call'=101
|
||||||
'crdt'=102
|
'watch-start'=102
|
||||||
'optional-one'=103
|
'watch-stop'=103
|
||||||
'exactly-one'=104
|
'subscribe'=104
|
||||||
'many-unique'=105
|
'unsubscribe'=105
|
||||||
'many'=106
|
'register'=106
|
||||||
'ordered'=107
|
'crdt'=107
|
||||||
'unit'=108
|
'optional-one'=108
|
||||||
'watch-handle'=109
|
'exactly-one'=109
|
||||||
'message'=110
|
'many-unique'=110
|
||||||
'atom-ref'=111
|
'many'=111
|
||||||
'interface-ref'=112
|
'ordered'=112
|
||||||
'optional'=113
|
'unit'=113
|
||||||
'list'=114
|
'watch-handle'=114
|
||||||
'record'=115
|
'message'=115
|
||||||
'bool'=116
|
'atom-ref'=116
|
||||||
'bytes'=117
|
'interface-ref'=117
|
||||||
'double'=118
|
'optional'=118
|
||||||
'int32'=119
|
'list'=119
|
||||||
'int64'=120
|
'record'=120
|
||||||
'string'=121
|
'bool'=121
|
||||||
'uint32'=122
|
'bytes'=122
|
||||||
'uint64'=123
|
'double'=123
|
||||||
'true'=124
|
'int32'=124
|
||||||
'false'=125
|
'int64'=125
|
||||||
'null'=126
|
'string'=126
|
||||||
'->'=127
|
'uint32'=127
|
||||||
':'=128
|
'uint64'=128
|
||||||
';'=129
|
'true'=129
|
||||||
','=130
|
'false'=130
|
||||||
'.'=131
|
'null'=131
|
||||||
'{'=132
|
'->'=132
|
||||||
'}'=133
|
':'=133
|
||||||
'['=134
|
';'=134
|
||||||
']'=135
|
','=135
|
||||||
'('=136
|
'.'=136
|
||||||
')'=137
|
'{'=137
|
||||||
'<'=138
|
'}'=138
|
||||||
'>'=139
|
'['=139
|
||||||
'&'=140
|
']'=140
|
||||||
'='=141
|
'('=141
|
||||||
|
')'=142
|
||||||
|
'<'=143
|
||||||
|
'>'=144
|
||||||
|
'&'=145
|
||||||
|
'='=146
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ComponentDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { PresentationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
|
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
|
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
|
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -251,6 +253,18 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
visitPackageExport?: (ctx: PackageExportContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.componentDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitComponentDecl?: (ctx: ComponentDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.presentationDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitPresentationDecl?: (ctx: PresentationDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuery`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuery`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -1333,9 +1333,25 @@ const lowerPackage = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const genericExports: GenericPackageExport[] = [];
|
const genericExports: GenericPackageExport[] = [];
|
||||||
|
const components: NonNullable<PackageRevision["components"]> = [];
|
||||||
const queries: QueryDeclaration[] = [];
|
const queries: QueryDeclaration[] = [];
|
||||||
const queryTemplates: QueryTemplate[] = [];
|
const queryTemplates: QueryTemplate[] = [];
|
||||||
const exports = context.packageExport().flatMap((exportContext): PackageExport[] => {
|
const exports = context.packageExport().flatMap((exportContext): PackageExport[] => {
|
||||||
|
const component = exportContext.componentDecl();
|
||||||
|
if (component) {
|
||||||
|
const runtime = identifier(component.identifier(1));
|
||||||
|
if (runtime !== "react")
|
||||||
|
loweringIssue(state, component, "unsupported-component-runtime", `Unsupported component runtime ${runtime}`);
|
||||||
|
components.push({
|
||||||
|
id: stringValue(component.stringLiteral()),
|
||||||
|
displayName: identifier(component.identifier(0)),
|
||||||
|
subject: new TypeSubstitution(state.types.environment()).application(
|
||||||
|
state.types.interface(component.interfaceType()),
|
||||||
|
),
|
||||||
|
runtime: "react",
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (exportContext.packageQuerySpecialization()) return [];
|
if (exportContext.packageQuerySpecialization()) return [];
|
||||||
const query = exportContext.packageQuery();
|
const query = exportContext.packageQuery();
|
||||||
if (query) {
|
if (query) {
|
||||||
@@ -1595,6 +1611,7 @@ const lowerPackage = (
|
|||||||
displayName: alias,
|
displayName: alias,
|
||||||
source,
|
source,
|
||||||
exports,
|
exports,
|
||||||
|
...(components.length ? { components } : {}),
|
||||||
...(queries.length ? { queries } : {}),
|
...(queries.length ? { queries } : {}),
|
||||||
...(queryTemplates.length ? { queryTemplates } : {}),
|
...(queryTemplates.length ? { queryTemplates } : {}),
|
||||||
...(queries.some((query) => query.argumentRequirements?.length)
|
...(queries.some((query) => query.argumentRequirements?.length)
|
||||||
@@ -2397,6 +2414,30 @@ const lowerWorkspace = (
|
|||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const presentations: NonNullable<WorkspaceRevision["presentations"]> = items.flatMap((item) => {
|
||||||
|
const presentation = item.presentationDecl();
|
||||||
|
if (!presentation) return [];
|
||||||
|
const packageName = identifier(presentation.identifier(1));
|
||||||
|
const componentName = identifier(presentation.identifier(2));
|
||||||
|
const pkg = requireSymbol(state, state.packages, packageName, presentation, "package");
|
||||||
|
const component = pkg?.definition?.components?.find((entry) => entry.displayName === componentName);
|
||||||
|
if (!component) {
|
||||||
|
loweringIssue(state, presentation, "unknown-component", `Unknown component ${packageName}.${componentName}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: stringValue(presentation.stringLiteral()),
|
||||||
|
displayName: identifier(presentation.identifier(0)),
|
||||||
|
subject: new TypeSubstitution(state.types.environment()).application(
|
||||||
|
state.types.interface(presentation.interfaceType()),
|
||||||
|
),
|
||||||
|
packageRevisionId: pkg!.revisionId,
|
||||||
|
componentId: component.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imports,
|
imports,
|
||||||
workspace: {
|
workspace: {
|
||||||
@@ -2417,6 +2458,7 @@ const lowerWorkspace = (
|
|||||||
),
|
),
|
||||||
conformances,
|
conformances,
|
||||||
constructors,
|
constructors,
|
||||||
|
...(presentations.length ? { presentations } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { InterfaceRevision, PackageController, WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
import { canonicalJson } from "./evolution.js";
|
||||||
|
|
||||||
|
export function validateControllers(
|
||||||
|
controllers: readonly PackageController[],
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
prefix: string,
|
||||||
|
): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [],
|
||||||
|
ids = new Set<string>();
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-controller" as const, path, message });
|
||||||
|
for (const [index, controller] of controllers.entries()) {
|
||||||
|
const path = `${prefix}[${index}]`;
|
||||||
|
if (!controller.id.trim() || ids.has(controller.id))
|
||||||
|
fail(`${path}.id`, "Controller identities must be nonempty and unique within the package");
|
||||||
|
ids.add(controller.id);
|
||||||
|
const iface = interfaces.find((i) => i.revisionId === controller.subject && !i.template);
|
||||||
|
if (!iface) {
|
||||||
|
fail(`${path}.subject`, "Controller subject must be an imported closed interface");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const operations = iface.members.flatMap((m) => m.operations);
|
||||||
|
const snapshot = operations.find((o) => o.id === controller.snapshot),
|
||||||
|
remote = operations.find((o) => o.id === controller.remote);
|
||||||
|
const unit = (value: unknown) => canonicalJson(value) === canonicalJson({ kind: "builtin", name: "unit" });
|
||||||
|
if (
|
||||||
|
!snapshot ||
|
||||||
|
snapshot.scope === "class" ||
|
||||||
|
snapshot.mode !== "call" ||
|
||||||
|
snapshot.execution?.kind !== "snapshot" ||
|
||||||
|
!snapshot.execution.targets.includes("server") ||
|
||||||
|
!unit(snapshot.inputType)
|
||||||
|
)
|
||||||
|
fail(`${path}.snapshot`, "Controller snapshot must be a server snapshot object call with unit input");
|
||||||
|
if (
|
||||||
|
!remote ||
|
||||||
|
remote.scope === "class" ||
|
||||||
|
remote.mode !== "call" ||
|
||||||
|
(remote.execution?.kind ?? "remote") !== "remote"
|
||||||
|
)
|
||||||
|
fail(`${path}.remote`, "Controller target must be a remote object call");
|
||||||
|
if (controller.input !== "unit" && controller.input !== "value")
|
||||||
|
fail(`${path}.input`, "Controller input must be unit or value");
|
||||||
|
else if (
|
||||||
|
remote &&
|
||||||
|
(controller.input === "unit"
|
||||||
|
? !unit(remote.inputType)
|
||||||
|
: snapshot && canonicalJson(snapshot.outputType) !== canonicalJson(remote.inputType))
|
||||||
|
)
|
||||||
|
fail(`${path}.input`, "Controller input mapping does not match the remote operation input");
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
export function validateWorkspaceControllers(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
return workspace.packageImports.flatMap((pkg, index) =>
|
||||||
|
validateControllers(pkg.controllers ?? [], workspace.interfaceImports, `packageImports[${index}].controllers`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** The file is declarative metadata, not an executable registration callback. */
|
||||||
|
export function parseControllerCatalog(text: string, interfaces: readonly InterfaceRevision[]): PackageController[] {
|
||||||
|
if (Buffer.byteLength(text) > 1048576) throw new Error("Controller catalog exceeds 1 MiB");
|
||||||
|
const value: unknown = JSON.parse(text);
|
||||||
|
const record = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
if (
|
||||||
|
!record(value) ||
|
||||||
|
value.schemaVersion !== 1 ||
|
||||||
|
Object.keys(value).some((k) => !["schemaVersion", "controllers"].includes(k)) ||
|
||||||
|
!Array.isArray(value.controllers) ||
|
||||||
|
value.controllers.length > 1000
|
||||||
|
)
|
||||||
|
throw new Error("Invalid controller catalog: expected schemaVersion 1 and at most 1000 controllers");
|
||||||
|
const fields = ["id", "subject", "snapshot", "remote", "input"];
|
||||||
|
for (const entry of value.controllers)
|
||||||
|
if (
|
||||||
|
!record(entry) ||
|
||||||
|
Object.keys(entry).length !== fields.length ||
|
||||||
|
fields.some((key) => typeof entry[key] !== "string")
|
||||||
|
)
|
||||||
|
throw new Error("Invalid controller entry: expected exactly id, subject, snapshot, remote, input strings");
|
||||||
|
const controllers = value.controllers as PackageController[];
|
||||||
|
const issues = validateControllers(controllers, interfaces, "controllers");
|
||||||
|
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
|
||||||
|
return controllers;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { WorkspaceRevision } from "./types.js";
|
||||||
|
import type { CapabilityValidationIssue } from "./validation.js";
|
||||||
|
|
||||||
|
/** Presentation metadata has no graph authority and cannot name arbitrary module URLs. */
|
||||||
|
export function validatePresentations(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
|
||||||
|
const issues: CapabilityValidationIssue[] = [];
|
||||||
|
const fail = (path: string, message: string) => issues.push({ code: "invalid-presentation", path, message });
|
||||||
|
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const packages = new Map(workspace.packageImports.map((entry) => [entry.revisionId, entry]));
|
||||||
|
const text = (value: string, path: string) => {
|
||||||
|
if (!value.trim()) fail(path, "Presentation identities and names must be nonempty");
|
||||||
|
};
|
||||||
|
const subject = (id: string, path: string) => {
|
||||||
|
const contract = interfaces.get(id as WorkspaceRevision["interfaceImports"][number]["revisionId"]);
|
||||||
|
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
|
||||||
|
};
|
||||||
|
for (const [index, pkg] of workspace.packageImports.entries()) {
|
||||||
|
const ids = new Set(pkg.exports.map((entry) => entry.id as string));
|
||||||
|
const names = new Set([
|
||||||
|
...pkg.exports.map((entry) => entry.displayName),
|
||||||
|
...(pkg.queries ?? []).map((entry) => entry.displayName),
|
||||||
|
...(pkg.queryTemplates ?? []).map((entry) => entry.declaration.displayName),
|
||||||
|
...(pkg.genericExports ?? []).map((entry) => entry.displayName),
|
||||||
|
]);
|
||||||
|
for (const [componentIndex, component] of (pkg.components ?? []).entries()) {
|
||||||
|
const path = `packageImports[${index}].components[${componentIndex}]`;
|
||||||
|
text(component.id, `${path}.id`);
|
||||||
|
text(component.displayName, `${path}.displayName`);
|
||||||
|
if (ids.has(component.id) || names.has(component.displayName))
|
||||||
|
fail(path, "Duplicate component identity or package member name");
|
||||||
|
ids.add(component.id);
|
||||||
|
names.add(component.displayName);
|
||||||
|
subject(component.subject, `${path}.subject`);
|
||||||
|
if (component.runtime !== "react") fail(`${path}.runtime`, "Unsupported component runtime");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = new Set<string>(),
|
||||||
|
names = new Set<string>();
|
||||||
|
for (const [index, presentation] of (workspace.presentations ?? []).entries()) {
|
||||||
|
const path = `presentations[${index}]`;
|
||||||
|
text(presentation.id, `${path}.id`);
|
||||||
|
text(presentation.displayName, `${path}.displayName`);
|
||||||
|
if (ids.has(presentation.id) || names.has(presentation.displayName))
|
||||||
|
fail(path, "Duplicate presentation identity or name");
|
||||||
|
ids.add(presentation.id);
|
||||||
|
names.add(presentation.displayName);
|
||||||
|
subject(presentation.subject, `${path}.subject`);
|
||||||
|
const component = packages
|
||||||
|
.get(presentation.packageRevisionId)
|
||||||
|
?.components?.find((entry) => entry.id === presentation.componentId);
|
||||||
|
if (!component) fail(`${path}.componentId`, "Presentation names no installed package component");
|
||||||
|
else if (component.subject !== presentation.subject)
|
||||||
|
fail(`${path}.subject`, `Component requires ${component.subject}, not ${presentation.subject}`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -288,7 +288,25 @@ export interface PackageConstructorExport extends PackageExportBase {
|
|||||||
|
|
||||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||||
|
|
||||||
|
/** A window presentation entry, never a graph operation or service export. */
|
||||||
|
export interface PackageComponent {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
subject: InterfaceRevisionId;
|
||||||
|
runtime: "react";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePresentation {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
subject: InterfaceRevisionId;
|
||||||
|
packageRevisionId: PackageRevisionId;
|
||||||
|
componentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
controllers?: PackageController[];
|
||||||
|
components?: PackageComponent[];
|
||||||
queries?: import("../query/types.js").QueryDeclaration[];
|
queries?: import("../query/types.js").QueryDeclaration[];
|
||||||
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||||
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||||
@@ -304,6 +322,15 @@ export interface PackageRevision {
|
|||||||
semanticMajor?: number;
|
semanticMajor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checked adapter registration. Implementations remain ordinary object capabilities. */
|
||||||
|
export interface PackageController {
|
||||||
|
id: string;
|
||||||
|
subject: InterfaceRevisionId;
|
||||||
|
snapshot: OperationId;
|
||||||
|
remote: OperationId;
|
||||||
|
input: "unit" | "value";
|
||||||
|
}
|
||||||
|
|
||||||
export type DependencyBinding =
|
export type DependencyBinding =
|
||||||
| { kind: "reflection"; selectors: string[]; via?: never }
|
| { kind: "reflection"; selectors: string[]; via?: never }
|
||||||
| {
|
| {
|
||||||
@@ -415,6 +442,7 @@ export interface AtomConstructorBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRevision {
|
export interface WorkspaceRevision {
|
||||||
|
presentations?: WorkspacePresentation[];
|
||||||
linkedQueries?: import("../query/link.js").LinkedQuery[];
|
linkedQueries?: import("../query/link.js").LinkedQuery[];
|
||||||
id: WorkspaceRevisionId;
|
id: WorkspaceRevisionId;
|
||||||
workspaceId: WorkspaceId;
|
workspaceId: WorkspaceId;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { isStorableType } from "./generics.js";
|
import { isStorableType } from "./generics.js";
|
||||||
import { validateInitialization } from "./initialization.js";
|
import { validateInitialization } from "./initialization.js";
|
||||||
|
import { validatePresentations } from "./presentations.js";
|
||||||
|
import { validateWorkspaceControllers } from "./controllers.js";
|
||||||
import { validateExecutionContracts } from "./execution.js";
|
import { validateExecutionContracts } from "./execution.js";
|
||||||
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -44,6 +46,8 @@ import { specializePackageExport } from "./generic-packages.js";
|
|||||||
import { isDeepStrictEqual } from "node:util";
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
|
||||||
export type CapabilityValidationIssueCode =
|
export type CapabilityValidationIssueCode =
|
||||||
|
| "invalid-controller"
|
||||||
|
| "invalid-presentation"
|
||||||
| "invalid-initialization"
|
| "invalid-initialization"
|
||||||
| "invalid-execution-contract"
|
| "invalid-execution-contract"
|
||||||
| "invalid-query-contract"
|
| "invalid-query-contract"
|
||||||
@@ -2213,7 +2217,12 @@ export const validateWorkspaceRevision = (workspace: WorkspaceRevision): Capabil
|
|||||||
validatePackages(workspace, issues, indexes);
|
validatePackages(workspace, issues, indexes);
|
||||||
validateConformances(workspace, issues, indexes);
|
validateConformances(workspace, issues, indexes);
|
||||||
validateConstructors(workspace, issues, indexes);
|
validateConstructors(workspace, issues, indexes);
|
||||||
issues.push(...validateExecutionContracts(workspace), ...validateInitialization(workspace));
|
issues.push(
|
||||||
|
...validateExecutionContracts(workspace),
|
||||||
|
...validateInitialization(workspace),
|
||||||
|
...validatePresentations(workspace),
|
||||||
|
...validateWorkspaceControllers(workspace),
|
||||||
|
);
|
||||||
return issues;
|
return issues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ export interface PackageArtifacts {
|
|||||||
sourceDigest: string;
|
sourceDigest: string;
|
||||||
versions: ExecutionPlan["definition"]["versions"];
|
versions: ExecutionPlan["definition"]["versions"];
|
||||||
implementations: ArtifactImplementation[];
|
implementations: ArtifactImplementation[];
|
||||||
|
components?: ComponentArtifact[];
|
||||||
|
}
|
||||||
|
export const componentSharedModules = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"@quixos/camino-react",
|
||||||
|
] as const;
|
||||||
|
export interface ComponentArtifact {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
compatibility: "quixos-react-component-v1";
|
||||||
|
path: string;
|
||||||
|
exportName: string;
|
||||||
|
sharedModules: Record<(typeof componentSharedModules)[number], string>;
|
||||||
|
files: ArtifactFile[];
|
||||||
|
contentDigest: string;
|
||||||
}
|
}
|
||||||
export interface ImplementationSelection {
|
export interface ImplementationSelection {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -119,12 +137,73 @@ export function implementationDigest(implementation: Omit<ArtifactImplementation
|
|||||||
files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export function componentArtifactDigest(component: Omit<ComponentArtifact, "contentDigest">): string {
|
||||||
|
return hexDigest({
|
||||||
|
...component,
|
||||||
|
files: [...component.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function parseArtifactFiles(value: unknown, path: string): ArtifactFile[] {
|
||||||
|
const files = array(value, path).map((value): ArtifactFile => {
|
||||||
|
const file = object(value, path);
|
||||||
|
keys(file, ["path", "digest", "bytes", "mediaType"], [], path);
|
||||||
|
if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0 || (file.bytes as number) > 256 * 1024 * 1024)
|
||||||
|
fail(path, "Artifact file exceeds size bound");
|
||||||
|
return {
|
||||||
|
path: artifactPath(file.path),
|
||||||
|
digest: digest(file.digest, path),
|
||||||
|
bytes: file.bytes as number,
|
||||||
|
mediaType: text(file.mediaType, path),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
unique(
|
||||||
|
files.map((file) => file.path),
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||||
|
}
|
||||||
|
function parseComponent(value: unknown, index: number): ComponentArtifact {
|
||||||
|
const path = `components[${index}]`,
|
||||||
|
entry = object(value, path);
|
||||||
|
keys(
|
||||||
|
entry,
|
||||||
|
["id", "subject", "compatibility", "path", "exportName", "sharedModules", "files", "contentDigest"],
|
||||||
|
[],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
if (entry.compatibility !== "quixos-react-component-v1") fail(path, "Unsupported component ABI");
|
||||||
|
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||||
|
const shared = object(entry.sharedModules, `${path}.sharedModules`);
|
||||||
|
keys(shared, [...componentSharedModules], [], `${path}.sharedModules`);
|
||||||
|
const sharedModules = Object.fromEntries(
|
||||||
|
componentSharedModules.map((name) => [name, artifactPath(shared[name])]),
|
||||||
|
) as ComponentArtifact["sharedModules"];
|
||||||
|
const entryPath = artifactPath(entry.path),
|
||||||
|
exportName = text(entry.exportName, path);
|
||||||
|
if (!/^[$A-Z_a-z][$\w]*$/.test(exportName)) fail(path, "Expected a JavaScript component export name");
|
||||||
|
for (const modulePath of [entryPath, ...Object.values(sharedModules)])
|
||||||
|
if (!files.some((file) => file.path === modulePath && file.mediaType === "text/javascript"))
|
||||||
|
fail(path, "Component entry and shared modules require declared JavaScript files");
|
||||||
|
const component: ComponentArtifact = {
|
||||||
|
id: text(entry.id, path),
|
||||||
|
subject: text(entry.subject, path),
|
||||||
|
compatibility: "quixos-react-component-v1",
|
||||||
|
path: entryPath,
|
||||||
|
exportName,
|
||||||
|
sharedModules,
|
||||||
|
files,
|
||||||
|
contentDigest: digest(entry.contentDigest, path),
|
||||||
|
};
|
||||||
|
const { contentDigest, ...body } = component;
|
||||||
|
if (componentArtifactDigest(body) !== contentDigest) fail(path, "Component metadata digest mismatch");
|
||||||
|
return component;
|
||||||
|
}
|
||||||
export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||||
const record = object(value, "manifest");
|
const record = object(value, "manifest");
|
||||||
keys(
|
keys(
|
||||||
record,
|
record,
|
||||||
["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"],
|
["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"],
|
||||||
[],
|
["components"],
|
||||||
"manifest",
|
"manifest",
|
||||||
);
|
);
|
||||||
if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version");
|
if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version");
|
||||||
@@ -152,26 +231,7 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
|||||||
if (entry.compatibility === "quixos-js-module-v1") text(entry.registryExport, `${path}.registryExport`);
|
if (entry.compatibility === "quixos-js-module-v1") text(entry.registryExport, `${path}.registryExport`);
|
||||||
const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`));
|
const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`));
|
||||||
unique(exports, `${path}.exports`);
|
unique(exports, `${path}.exports`);
|
||||||
const files = array(entry.files, `${path}.files`).map((value): ArtifactFile => {
|
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||||
const file = object(value, `${path}.file`);
|
|
||||||
keys(file, ["path", "digest", "bytes", "mediaType"], [], `${path}.file`);
|
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(file.bytes) ||
|
|
||||||
(file.bytes as number) < 0 ||
|
|
||||||
(file.bytes as number) > 256 * 1024 * 1024
|
|
||||||
)
|
|
||||||
fail(path, "Artifact file exceeds size bound");
|
|
||||||
return {
|
|
||||||
path: artifactPath(file.path),
|
|
||||||
digest: digest(file.digest, path),
|
|
||||||
bytes: file.bytes as number,
|
|
||||||
mediaType: text(file.mediaType, path),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
unique(
|
|
||||||
files.map((file) => file.path),
|
|
||||||
`${path}.files`,
|
|
||||||
);
|
|
||||||
const entryPath = artifactPath(entry.path);
|
const entryPath = artifactPath(entry.path);
|
||||||
if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest");
|
if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest");
|
||||||
const result: ArtifactImplementation = {
|
const result: ArtifactImplementation = {
|
||||||
@@ -194,6 +254,13 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
|||||||
implementations.map((implementation) => implementation.id),
|
implementations.map((implementation) => implementation.id),
|
||||||
"implementations",
|
"implementations",
|
||||||
);
|
);
|
||||||
|
const components =
|
||||||
|
record.components === undefined ? undefined : array(record.components, "components").map(parseComponent);
|
||||||
|
if (components)
|
||||||
|
unique(
|
||||||
|
components.map((component) => component.id),
|
||||||
|
"components",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
packageRevisionId: text(record.packageRevisionId, "packageRevisionId"),
|
packageRevisionId: text(record.packageRevisionId, "packageRevisionId"),
|
||||||
@@ -201,11 +268,20 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
|||||||
sourceDigest: digest(record.sourceDigest, "sourceDigest"),
|
sourceDigest: digest(record.sourceDigest, "sourceDigest"),
|
||||||
versions: versions as unknown as PackageArtifacts["versions"],
|
versions: versions as unknown as PackageArtifacts["versions"],
|
||||||
implementations,
|
implementations,
|
||||||
|
...(components ? { components } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
|
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
|
||||||
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
||||||
fail(expected.revision, "Substituted binding schema or source revision");
|
fail(expected.revision, "Substituted binding schema or source revision");
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
const declaration = expected.components?.find((entry) => entry.id === component.id);
|
||||||
|
if (!declaration || declaration.subject !== component.subject || declaration.runtime !== "react")
|
||||||
|
fail(component.id, "Component differs from its declared subject/runtime contract");
|
||||||
|
}
|
||||||
|
for (const component of expected.components ?? [])
|
||||||
|
if (!manifest.components?.some((entry) => entry.id === component.id))
|
||||||
|
fail(component.id, "Missing component artifact");
|
||||||
for (const implementation of manifest.implementations)
|
for (const implementation of manifest.implementations)
|
||||||
for (const id of implementation.exports) {
|
for (const id of implementation.exports) {
|
||||||
const entry = expected.exports.find((e) => e.id === id);
|
const entry = expected.exports.find((e) => e.id === id);
|
||||||
@@ -272,6 +348,19 @@ export async function checkExecutionWorld(
|
|||||||
digest: implementation.contentDigest,
|
digest: implementation.contentDigest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
for (const file of component.files) {
|
||||||
|
const existing = files.get(file.path);
|
||||||
|
if (existing && canonicalJson(existing) !== canonicalJson(file))
|
||||||
|
fail(file.path, "Conflicting file descriptors");
|
||||||
|
files.set(file.path, file);
|
||||||
|
}
|
||||||
|
artifacts.push({
|
||||||
|
binding: JSON.stringify([expected.revision, "component", component.id]),
|
||||||
|
target: "browser-renderer",
|
||||||
|
digest: component.contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const file of files.values()) {
|
for (const file of files.values()) {
|
||||||
const bytes = await read(expected.revision, file.path, file.bytes);
|
const bytes = await read(expected.revision, file.path, file.bytes);
|
||||||
if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest)
|
if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type {
|
||||||
|
InterfaceRevision,
|
||||||
|
InterfaceOperation,
|
||||||
|
ExecutionContract,
|
||||||
|
PackageRevision,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { portableValueSchema, tsType } from "../bindings/portable.js";
|
||||||
|
import { type ExecutionPlan, resolvePlannedOperation, hexDigest, ExecutionPlanError } from "./plan.js";
|
||||||
|
|
||||||
|
/** Public shape only: no slot inventory, implementation binding, or workspace identity. */
|
||||||
|
export interface PublicOperationContract {
|
||||||
|
interface: string;
|
||||||
|
operation: string;
|
||||||
|
scope: "object" | "class";
|
||||||
|
execution: ExecutionContract;
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
}
|
||||||
|
export function publicOperationContract(
|
||||||
|
iface: InterfaceRevision,
|
||||||
|
operation: InterfaceOperation,
|
||||||
|
): PublicOperationContract {
|
||||||
|
if (iface.template || operation.mode !== "call" || !iface.members.some((m) => m.operations.includes(operation)))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"UNSUPPORTED_CLIENT_CONTRACT",
|
||||||
|
iface.revisionId,
|
||||||
|
"Expected a closed interface call operation",
|
||||||
|
);
|
||||||
|
const execution = operation.execution ?? { kind: "remote" };
|
||||||
|
return {
|
||||||
|
interface: iface.revisionId,
|
||||||
|
operation: operation.id,
|
||||||
|
scope: operation.scope ?? "object",
|
||||||
|
execution: execution.kind === "remote" ? execution : { ...execution, targets: [...execution.targets].sort() },
|
||||||
|
input: portableValueSchema(operation.inputType),
|
||||||
|
output: portableValueSchema(operation.outputType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Resolve a public contract to internal dispatch. This does not authorize the receiver. */
|
||||||
|
export function linkInterfaceOperation(plan: ExecutionPlan, atom: string, contract: PublicOperationContract) {
|
||||||
|
if (hexDigest(plan.definition) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN", atom, "Invalid execution plan digest");
|
||||||
|
const iface = plan.definition.workspace.interfaceImports.find((i) => i.revisionId === contract.interface);
|
||||||
|
const operation = iface?.members.flatMap((m) => m.operations).find((o) => o.id === contract.operation);
|
||||||
|
if (!iface || !operation || canonicalJson(publicOperationContract(iface, operation)) !== canonicalJson(contract))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CLIENT_CONTRACT_MISMATCH",
|
||||||
|
`${contract.interface}/${contract.operation}`,
|
||||||
|
"Installed interface differs from the imported operation contract",
|
||||||
|
);
|
||||||
|
return resolvePlannedOperation(plan, atom, contract.interface, contract.operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicQueryContract {
|
||||||
|
package: string;
|
||||||
|
query: string;
|
||||||
|
definitionDigest: string;
|
||||||
|
root: { kind: "object" | "workspace-interface"; interface: string };
|
||||||
|
placement: "server" | "replica" | "local";
|
||||||
|
sourceQuery?: string;
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
}
|
||||||
|
export function publicQueryContract(pkg: PackageRevision, queryId: string): PublicQueryContract {
|
||||||
|
const query = pkg.checkedQueries?.find((q) => q.declaration.id === queryId);
|
||||||
|
if (!query)
|
||||||
|
throw new ExecutionPlanError("UNCHECKED_QUERY", `${pkg.revisionId}/${queryId}`, "Expected a checked package query");
|
||||||
|
const placement = query.declaration.placement;
|
||||||
|
return {
|
||||||
|
package: pkg.revisionId,
|
||||||
|
query: queryId,
|
||||||
|
definitionDigest: query.definitionDigest,
|
||||||
|
root: query.declaration.root,
|
||||||
|
placement: placement?.kind ?? "server",
|
||||||
|
...(placement?.kind === "local" ? { sourceQuery: placement.sourceQueryId } : {}),
|
||||||
|
input: portableValueSchema(query.variables),
|
||||||
|
output: portableValueSchema(query.output),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) {
|
||||||
|
if (hexDigest(plan.definition) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError("INVALID_PLAN", contract.query, "Invalid execution plan digest");
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === contract.package);
|
||||||
|
if (!pkg || canonicalJson(publicQueryContract(pkg, contract.query)) !== canonicalJson(contract))
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CLIENT_QUERY_MISMATCH",
|
||||||
|
`${contract.package}/${contract.query}`,
|
||||||
|
"Installed query differs from the imported query contract",
|
||||||
|
);
|
||||||
|
const query = plan.definition.queries.find(
|
||||||
|
(q) => q.query.packageRevisionId === contract.package && q.query.checked.declaration.id === contract.query,
|
||||||
|
);
|
||||||
|
if (!query) throw new ExecutionPlanError("UNINSTALLED_QUERY", contract.query, "No installed query binding");
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate only the explicitly imported interfaces. No concrete atom inventory is needed. */
|
||||||
|
export function generateInterfaceClientBindings(
|
||||||
|
imports: Readonly<Record<string, InterfaceRevision>>,
|
||||||
|
runtimeModule = "@quixos/camino-replica-core",
|
||||||
|
): string {
|
||||||
|
const entries = Object.entries(imports).map(([alias, iface]) => {
|
||||||
|
if (iface.template)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"OPEN_CLIENT_CONTRACT",
|
||||||
|
alias,
|
||||||
|
"Specialize the interface before generating a client",
|
||||||
|
);
|
||||||
|
const members = iface.members.map((member) => {
|
||||||
|
const operations = member.operations
|
||||||
|
.filter((op) => op.mode === "call")
|
||||||
|
.map(
|
||||||
|
(op) =>
|
||||||
|
`${JSON.stringify(op.displayName)}: defineInterfaceOperation<${tsType(op.inputType)}, ${tsType(op.outputType)}>(${JSON.stringify(publicOperationContract(iface, op))})`,
|
||||||
|
);
|
||||||
|
return `${JSON.stringify(member.displayName)}: {${operations.join(",\n")}}`;
|
||||||
|
});
|
||||||
|
return `${JSON.stringify(alias)}: {${members.join(",\n")}}`;
|
||||||
|
});
|
||||||
|
return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineInterfaceOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from "./artifacts.js";
|
|||||||
export * from "./ports.js";
|
export * from "./ports.js";
|
||||||
export * from "./files.js";
|
export * from "./files.js";
|
||||||
export * from "./installation.js";
|
export * from "./installation.js";
|
||||||
|
export * from "./client-contracts.js";
|
||||||
|
|
||||||
export * from "./receipts.js";
|
export * from "./receipts.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,73 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { loadCheckedArtifactWorld } from "./receipts.js";
|
import { loadCheckedArtifactWorld } from "./receipts.js";
|
||||||
import { contentArtifactUrl } from "./files.js";
|
import { contentArtifactUrl } from "./files.js";
|
||||||
import { selectArtifactImplementation, type ArtifactImplementation } from "./artifacts.js";
|
import { selectArtifactImplementation, type ArtifactImplementation, type ComponentArtifact } from "./artifacts.js";
|
||||||
import { ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||||
|
import {
|
||||||
|
publicOperationContract,
|
||||||
|
publicQueryContract,
|
||||||
|
type PublicOperationContract,
|
||||||
|
type PublicQueryContract,
|
||||||
|
} from "./client-contracts.js";
|
||||||
|
|
||||||
|
/** Activation is assigned by graph authority, never by a package artifact. */
|
||||||
|
export interface ActiveExecutionIdentity {
|
||||||
|
readonly workspace: string;
|
||||||
|
readonly world: string;
|
||||||
|
readonly plan: string;
|
||||||
|
readonly epoch: string;
|
||||||
|
}
|
||||||
|
/** Host-owned execution selection. The lifecycle manager fills its checked inputs. */
|
||||||
|
export interface ExecutionGroupIdentity {
|
||||||
|
readonly logicalId: string;
|
||||||
|
readonly executionDigest: string;
|
||||||
|
}
|
||||||
|
export interface ClientContractCatalog {
|
||||||
|
readonly operations: readonly PublicOperationContract[];
|
||||||
|
readonly queries: readonly PublicQueryContract[];
|
||||||
|
readonly components: readonly InstalledComponent[];
|
||||||
|
readonly presentations: readonly { id: string; subject: string; packageRevision: string; component: string }[];
|
||||||
|
}
|
||||||
|
/** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */
|
||||||
|
export function clientContractCatalog(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
installation: ExecutionInstallation,
|
||||||
|
requestedPackages: readonly string[],
|
||||||
|
): ClientContractCatalog {
|
||||||
|
if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CATALOG_PLAN_MISMATCH",
|
||||||
|
plan.digest,
|
||||||
|
"Installation and public catalog must use the same checked plan",
|
||||||
|
);
|
||||||
|
const packages = requestedPackages.map((revision) => {
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === revision);
|
||||||
|
if (!pkg)
|
||||||
|
throw new ExecutionPlanError("UNINSTALLED_PACKAGE", revision, "Requested catalog package is not installed");
|
||||||
|
return pkg;
|
||||||
|
});
|
||||||
|
// Interface metadata contains no authority or private implementation configuration.
|
||||||
|
// A later admission optimization can narrow this set without changing operation identities.
|
||||||
|
const operations = plan.definition.workspace.interfaceImports
|
||||||
|
.filter((i) => !i.template)
|
||||||
|
.flatMap((iface) =>
|
||||||
|
iface.members.flatMap((member) =>
|
||||||
|
member.operations
|
||||||
|
.filter((o) => o.mode === "call")
|
||||||
|
.map((operation) => publicOperationContract(iface, operation)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
operations,
|
||||||
|
queries: packages.flatMap((pkg) =>
|
||||||
|
(pkg.checkedQueries ?? []).map((q) => publicQueryContract(pkg, q.declaration.id)),
|
||||||
|
),
|
||||||
|
components: installation.components.filter((c) => requestedPackages.includes(c.packageRevision)),
|
||||||
|
presentations: (plan.definition.presentations ?? [])
|
||||||
|
.filter((p) => requestedPackages.includes(p.packageRevisionId))
|
||||||
|
.map((p) => ({ id: p.id, subject: p.subject, packageRevision: p.packageRevisionId, component: p.componentId })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PackageInstallationSelection {
|
export interface PackageInstallationSelection {
|
||||||
readonly browser?: { readonly id?: string };
|
readonly browser?: { readonly id?: string };
|
||||||
@@ -17,7 +82,7 @@ export interface ExecutionInstallation {
|
|||||||
readonly packages: readonly {
|
readonly packages: readonly {
|
||||||
readonly revision: string;
|
readonly revision: string;
|
||||||
readonly root: string;
|
readonly root: string;
|
||||||
readonly service: ArtifactImplementation;
|
readonly service?: ArtifactImplementation;
|
||||||
readonly module?: ArtifactImplementation;
|
readonly module?: ArtifactImplementation;
|
||||||
readonly browserURL?: string;
|
readonly browserURL?: string;
|
||||||
readonly browserRegistry?: string;
|
readonly browserRegistry?: string;
|
||||||
@@ -25,6 +90,17 @@ export interface ExecutionInstallation {
|
|||||||
readonly routes: Readonly<Record<string, { readonly path: string; readonly mediaType: string }>>;
|
readonly routes: Readonly<Record<string, { readonly path: string; readonly mediaType: string }>>;
|
||||||
readonly coreURL?: string;
|
readonly coreURL?: string;
|
||||||
readonly engineURL?: string;
|
readonly engineURL?: string;
|
||||||
|
readonly components: readonly InstalledComponent[];
|
||||||
|
}
|
||||||
|
/** Safe to expose in a presentation catalog: no source paths, state slots, or host configuration. */
|
||||||
|
export interface InstalledComponent {
|
||||||
|
readonly packageRevision: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly subject: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly exportName: string;
|
||||||
|
readonly compatibility: ComponentArtifact["compatibility"];
|
||||||
|
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
||||||
}
|
}
|
||||||
/** Verify candidate receipts before assembling process entrypoints and browser assets.
|
/** Verify candidate receipts before assembling process entrypoints and browser assets.
|
||||||
* This grants no authority to activate a world or run optional modules in process. */
|
* This grants no authority to activate a world or run optional modules in process. */
|
||||||
@@ -43,18 +119,49 @@ export async function loadExecutionInstallation(
|
|||||||
fail(revision, "Selection names a package outside this checked world");
|
fail(revision, "Selection names a package outside this checked world");
|
||||||
const routes: Record<string, { path: string; mediaType: string }> = Object.create(null);
|
const routes: Record<string, { path: string; mediaType: string }> = Object.create(null);
|
||||||
const packages: ExecutionInstallation["packages"][number][] = [];
|
const packages: ExecutionInstallation["packages"][number][] = [];
|
||||||
|
const components: InstalledComponent[] = [];
|
||||||
|
const sharedComponentModules = new Map<string, string>();
|
||||||
let coreURL: string | undefined, engineURL: string | undefined;
|
let coreURL: string | undefined, engineURL: string | undefined;
|
||||||
for (const manifest of loaded.manifests) {
|
for (const manifest of loaded.manifests) {
|
||||||
const revision = manifest.packageRevisionId;
|
const revision = manifest.packageRevisionId;
|
||||||
const root = roots[revision]!;
|
const root = roots[revision]!;
|
||||||
const selection = selections[revision] ?? {};
|
const selection = selections[revision] ?? {};
|
||||||
const exports = plan.definition.packages.find((pkg) => pkg.revision === revision)!.exports;
|
const exports = plan.definition.packages.find((pkg) => pkg.revision === revision)!.exports;
|
||||||
const service = selectArtifactImplementation(manifest, {
|
const service =
|
||||||
...selection.service,
|
exports.length || selection.service
|
||||||
target: "server",
|
? selectArtifactImplementation(manifest, {
|
||||||
compatibility: "quixos-service-v1",
|
...selection.service,
|
||||||
exports: exports.map((entry) => entry.id),
|
target: "server",
|
||||||
});
|
compatibility: "quixos-service-v1",
|
||||||
|
exports: exports.map((entry) => entry.id),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
for (const component of manifest.components ?? []) {
|
||||||
|
const sharedModules = Object.fromEntries(
|
||||||
|
Object.entries(component.sharedModules).map(([name, filePath]) => {
|
||||||
|
const file = component.files.find((file) => file.path === filePath)!;
|
||||||
|
const url = contentArtifactUrl(file),
|
||||||
|
previous = sharedComponentModules.get(name);
|
||||||
|
if (previous && previous !== url) fail(component.id, `Components disagree on shared module ${name}`);
|
||||||
|
sharedComponentModules.set(name, url);
|
||||||
|
return [name, url];
|
||||||
|
}),
|
||||||
|
) as InstalledComponent["sharedModules"];
|
||||||
|
for (const file of component.files) {
|
||||||
|
const url = contentArtifactUrl(file);
|
||||||
|
if (routes[url] && routes[url].mediaType !== file.mediaType) fail(url, "Conflicting artifact media types");
|
||||||
|
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
|
||||||
|
}
|
||||||
|
components.push({
|
||||||
|
packageRevision: revision,
|
||||||
|
id: component.id,
|
||||||
|
subject: component.subject,
|
||||||
|
compatibility: component.compatibility,
|
||||||
|
exportName: component.exportName,
|
||||||
|
sharedModules,
|
||||||
|
url: contentArtifactUrl(component.files.find((file) => file.path === component.path)!),
|
||||||
|
});
|
||||||
|
}
|
||||||
const module = selection.module
|
const module = selection.module
|
||||||
? selectArtifactImplementation(manifest, {
|
? selectArtifactImplementation(manifest, {
|
||||||
...selection.module,
|
...selection.module,
|
||||||
@@ -68,7 +175,7 @@ export async function loadExecutionInstallation(
|
|||||||
);
|
);
|
||||||
if (!browserExports.length) {
|
if (!browserExports.length) {
|
||||||
if (selection.browser) fail(revision, "Package has no browser exports to select");
|
if (selection.browser) fail(revision, "Package has no browser exports to select");
|
||||||
packages.push({ revision, root, service, ...(module ? { module } : {}) });
|
packages.push({ revision, root, ...(service ? { service } : {}), ...(module ? { module } : {}) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const browser = selectArtifactImplementation(manifest, {
|
const browser = selectArtifactImplementation(manifest, {
|
||||||
@@ -97,7 +204,7 @@ export async function loadExecutionInstallation(
|
|||||||
packages.push({
|
packages.push({
|
||||||
revision,
|
revision,
|
||||||
root,
|
root,
|
||||||
service,
|
...(service ? { service } : {}),
|
||||||
...(module ? { module } : {}),
|
...(module ? { module } : {}),
|
||||||
browserURL: contentArtifactUrl(entry),
|
browserURL: contentArtifactUrl(entry),
|
||||||
browserRegistry: browser.registryExport,
|
browserRegistry: browser.registryExport,
|
||||||
@@ -108,6 +215,7 @@ export async function loadExecutionInstallation(
|
|||||||
plan: plan.digest,
|
plan: plan.digest,
|
||||||
world: loaded.digest,
|
world: loaded.digest,
|
||||||
packages,
|
packages,
|
||||||
|
components,
|
||||||
routes,
|
routes,
|
||||||
...(coreURL ? { coreURL, engineURL } : {}),
|
...(coreURL ? { coreURL, engineURL } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface PlannedQuery {
|
|||||||
admittedOperations: string[];
|
admittedOperations: string[];
|
||||||
}
|
}
|
||||||
export interface PlanPackage {
|
export interface PlanPackage {
|
||||||
|
controllers?: NonNullable<WorkspaceRevision["packageImports"][number]["controllers"]>;
|
||||||
|
components?: NonNullable<WorkspaceRevision["packageImports"][number]["components"]>;
|
||||||
revision: string;
|
revision: string;
|
||||||
source: WorkspaceRevision["packageImports"][number]["source"];
|
source: WorkspaceRevision["packageImports"][number]["source"];
|
||||||
sourceDigest: string;
|
sourceDigest: string;
|
||||||
@@ -65,6 +67,7 @@ export interface PlanPackage {
|
|||||||
exports: { id: string; execution: ExecutionContract }[];
|
exports: { id: string; execution: ExecutionContract }[];
|
||||||
}
|
}
|
||||||
export interface ExecutionPlanDefinition {
|
export interface ExecutionPlanDefinition {
|
||||||
|
presentations?: NonNullable<WorkspaceRevision["presentations"]>;
|
||||||
codec: "camino-execution-plan-v1";
|
codec: "camino-execution-plan-v1";
|
||||||
workspace: WorkspaceRevision;
|
workspace: WorkspaceRevision;
|
||||||
bindingSchema: BindingSchema;
|
bindingSchema: BindingSchema;
|
||||||
@@ -421,11 +424,14 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
|||||||
codec: "camino-execution-plan-v1",
|
codec: "camino-execution-plan-v1",
|
||||||
workspace,
|
workspace,
|
||||||
bindingSchema: normalizedSchema,
|
bindingSchema: normalizedSchema,
|
||||||
|
...(workspace.presentations?.length ? { presentations: sorted(workspace.presentations, (p) => p.id) } : {}),
|
||||||
packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => ({
|
packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => ({
|
||||||
revision: p.revisionId,
|
revision: p.revisionId,
|
||||||
source: p.source,
|
source: p.source,
|
||||||
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
|
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
|
||||||
bindingDigest: hexDigest(normalizedSchema),
|
bindingDigest: hexDigest(normalizedSchema),
|
||||||
|
...(p.controllers?.length ? { controllers: sorted(p.controllers, (controller) => controller.id) } : {}),
|
||||||
|
...(p.components?.length ? { components: sorted(p.components, (component) => component.id) } : {}),
|
||||||
exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })),
|
exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })),
|
||||||
})),
|
})),
|
||||||
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
|
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js";
|
||||||
|
import {
|
||||||
|
compileExecutionPlan,
|
||||||
|
generateInterfaceClientBindings,
|
||||||
|
publicOperationContract,
|
||||||
|
linkInterfaceOperation,
|
||||||
|
publicQueryContract,
|
||||||
|
linkPackageQuery,
|
||||||
|
} from "../src/execution-world/index.js";
|
||||||
|
import { compileQuery } from "../src/query/compile.js";
|
||||||
|
import { parseControllerCatalog, validateControllers } from "../src/capability-model/controllers.js";
|
||||||
|
const source = { repository: "https://example.test/contracts.git", commit: "a".repeat(40) };
|
||||||
|
const parsed = compileCapabilityResourceSource(
|
||||||
|
`interface Data id "data" revision "data@1" {
|
||||||
|
queryable portable value label id "label" : string { get id "label:get" execution snapshot targets [browser, server]; }
|
||||||
|
operation publish id "publish" : string -> unit { call id "publish:call"; }
|
||||||
|
}`,
|
||||||
|
{ source },
|
||||||
|
);
|
||||||
|
assert.ok(parsed.ok && parsed.resource.kind === "interface", JSON.stringify(parsed.diagnostics));
|
||||||
|
const iface = parsed.resource.revision;
|
||||||
|
const environment = { interfaces: new Map([["Data", iface]]), interfaceClosure: [iface] };
|
||||||
|
const compile = async () => {
|
||||||
|
const pkg = compileCapabilityResourceSource(
|
||||||
|
`import interface Data; package Views id "views" revision "views@1" {
|
||||||
|
query Labels id "labels" root workspace Data document "labels.graphql" operation "Labels" { replica; }
|
||||||
|
operation publish id "export:publish" : string -> unit mode call receiver any;
|
||||||
|
}`,
|
||||||
|
{ source, environment },
|
||||||
|
);
|
||||||
|
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||||
|
const revision = pkg.resource.revision;
|
||||||
|
revision.checkedQueries = [
|
||||||
|
await compileQuery(
|
||||||
|
revision.queries![0]!,
|
||||||
|
[iface],
|
||||||
|
async () => "query Labels { root { objects(first: 10) { entries { node { label } } } } }",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const result = compileCapabilitySource(
|
||||||
|
`workspace Test id "test" revision "test@1" commit "${source.commit}" {
|
||||||
|
import interface Data; import package Views;
|
||||||
|
atom One id "one"; atom Two id "two";
|
||||||
|
${["One", "Two"]
|
||||||
|
.map(
|
||||||
|
(atom) => `conform ${atom} as Data id "${atom}:data" {
|
||||||
|
private state ${atom}Label id "${atom}:label" on ${atom} : string policy register default "";
|
||||||
|
bind label.get to state ${atom}Label.read;
|
||||||
|
bind publish.call to package Views.publish;
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.join("\n")}
|
||||||
|
}`,
|
||||||
|
"workspace.qx",
|
||||||
|
{ ...environment, packages: new Map([["Views", revision]]) },
|
||||||
|
);
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
return compileExecutionPlan(result.workspace);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("one imported operation contract links to two atom implementations without containing either", async () => {
|
||||||
|
const plan = await compile();
|
||||||
|
const contract = publicOperationContract(iface, iface.members[0]!.operations[0]!);
|
||||||
|
assert.equal(linkInterfaceOperation(plan, "one", contract).atomId, "one");
|
||||||
|
assert.equal(linkInterfaceOperation(plan, "two", contract).atomId, "two");
|
||||||
|
const generated = generateInterfaceClientBindings({ Data: iface });
|
||||||
|
assert.ok(generated.includes("defineInterfaceOperation<null, string>"));
|
||||||
|
for (const privateValue of [plan.digest, "One:label", '"atomId"', '"binding"'])
|
||||||
|
assert.equal(generated.includes(privateValue), false);
|
||||||
|
assert.throws(() => linkInterfaceOperation(plan, "absent", contract), /UNINSTALLED_OPERATION/);
|
||||||
|
for (const change of [{ scope: "class" }, { input: { kind: "string" } }, { execution: { kind: "remote" } }])
|
||||||
|
assert.throws(
|
||||||
|
() => linkInterfaceOperation(plan, "one", { ...contract, ...change } as typeof contract),
|
||||||
|
/CLIENT_CONTRACT_MISMATCH/,
|
||||||
|
);
|
||||||
|
assert.throws(() => linkInterfaceOperation({ ...plan, digest: "bad" }, "one", contract), /INVALID_PLAN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("package query handles bind the checked definition, placement and codecs", async () => {
|
||||||
|
const plan = await compile();
|
||||||
|
const contract = publicQueryContract(plan.definition.workspace.packageImports[0]!, "labels");
|
||||||
|
assert.equal(linkPackageQuery(plan, contract).query.checked.declaration.id, "labels");
|
||||||
|
for (const change of [{ placement: "local" }, { definitionDigest: "bad" }, { output: null }])
|
||||||
|
assert.throws(() => linkPackageQuery(plan, { ...contract, ...change } as typeof contract), /CLIENT_QUERY_MISMATCH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("controller templates check object capability effects and exact input mapping", () => {
|
||||||
|
const catalog = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
controllers: [
|
||||||
|
{ id: "publish-label", subject: "data@1", snapshot: "label:get", remote: "publish:call", input: "value" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(parseControllerCatalog(JSON.stringify(catalog), [iface]).length, 1);
|
||||||
|
for (const change of [
|
||||||
|
{ input: "unit" },
|
||||||
|
{ input: "callback" },
|
||||||
|
{ subject: "missing" },
|
||||||
|
{ snapshot: "publish:call" },
|
||||||
|
{ remote: "label:get" },
|
||||||
|
{ url: "https://example.test" },
|
||||||
|
]) {
|
||||||
|
const invalid = { ...catalog, controllers: [{ ...catalog.controllers[0], ...change }] };
|
||||||
|
assert.throws(() => parseControllerCatalog(JSON.stringify(invalid), [iface]), /Controller|controller/);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseControllerCatalog(
|
||||||
|
JSON.stringify({ ...catalog, controllers: [...catalog.controllers, ...catalog.controllers] }),
|
||||||
|
[iface],
|
||||||
|
),
|
||||||
|
/unique/,
|
||||||
|
);
|
||||||
|
const classIface = structuredClone(iface);
|
||||||
|
classIface.members[1]!.operations[0]!.scope = "class";
|
||||||
|
assert.ok(
|
||||||
|
validateControllers(parseControllerCatalog(JSON.stringify(catalog), [iface]), [classIface], "controllers").length,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceSource,
|
||||||
|
compileCapabilitySource,
|
||||||
|
formatQx,
|
||||||
|
parseQx,
|
||||||
|
walkSyntax,
|
||||||
|
} from "../src/capability-language/index.js";
|
||||||
|
import {
|
||||||
|
compileExecutionPlan,
|
||||||
|
checkExecutionWorld,
|
||||||
|
componentArtifactDigest,
|
||||||
|
componentSharedModules,
|
||||||
|
expectedArtifactReceipt,
|
||||||
|
loadExecutionInstallation,
|
||||||
|
clientContractCatalog,
|
||||||
|
type ComponentArtifact,
|
||||||
|
type PackageArtifacts,
|
||||||
|
} from "../src/execution-world/index.js";
|
||||||
|
import { validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||||
|
|
||||||
|
const source = { repository: "https://example.test/components.git", commit: "a".repeat(40) };
|
||||||
|
const contract = (name: string) => {
|
||||||
|
const result = compileCapabilityResourceSource(
|
||||||
|
`interface ${name} id "interface:${name}" revision "interface:${name}@1" { }`,
|
||||||
|
{ source },
|
||||||
|
);
|
||||||
|
assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics));
|
||||||
|
return result.resource.revision;
|
||||||
|
};
|
||||||
|
const data = contract("Data"),
|
||||||
|
other = contract("Other");
|
||||||
|
const interfaces = new Map([
|
||||||
|
["Data", data],
|
||||||
|
["Other", other],
|
||||||
|
]);
|
||||||
|
const componentSource = (body = 'component Card id "component:card" subject Data runtime react;') =>
|
||||||
|
`import interface Data; package UI id "package:ui" revision "package:ui@1" { ${body} }`;
|
||||||
|
const compilePackage = (body?: string) =>
|
||||||
|
compileCapabilityResourceSource(componentSource(body), {
|
||||||
|
source,
|
||||||
|
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||||
|
});
|
||||||
|
const workspace = (presentation = 'presentation Card id "presentation:card" for Data using UI.Card;') => {
|
||||||
|
const pkg = compilePackage();
|
||||||
|
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||||
|
return compileCapabilitySource(
|
||||||
|
`workspace Example id "workspace:example" revision "workspace:example@1" commit "${source.commit}" {
|
||||||
|
import interface Data; import interface Other; import package UI;
|
||||||
|
atom One id "atom:one"; atom Two id "atom:two";
|
||||||
|
conform One as Data id "conformance:one:data" { }
|
||||||
|
conform Two as Data id "conformance:two:data" { }
|
||||||
|
${presentation}
|
||||||
|
}`,
|
||||||
|
"workspace.qx",
|
||||||
|
{
|
||||||
|
interfaces,
|
||||||
|
interfaceClosure: [...interfaces.values()],
|
||||||
|
packages: new Map([["UI", pkg.resource.revision]]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("interface-only component declarations serve multiple conforming atoms without capability exports", () => {
|
||||||
|
const result = workspace();
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
const plan = compileExecutionPlan(result.workspace);
|
||||||
|
assert.equal(plan.definition.packages[0]!.exports.length, 0);
|
||||||
|
assert.deepEqual(plan.definition.packages[0]!.components, [
|
||||||
|
{
|
||||||
|
id: "component:card",
|
||||||
|
displayName: "Card",
|
||||||
|
subject: "interface:Data@1",
|
||||||
|
runtime: "react",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(plan.definition.presentations?.[0]?.componentId, "component:card");
|
||||||
|
assert.equal(plan.definition.workspace.conformances.length, 2);
|
||||||
|
assert.equal(plan.definition.operations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("component declarations survive formatting and syntax inspection", () => {
|
||||||
|
const text = formatQx(componentSource());
|
||||||
|
const parsed = parseQx(text);
|
||||||
|
assert.deepEqual(parsed.diagnostics, []);
|
||||||
|
assert.ok([...walkSyntax(parsed.root)].some((node) => node.kind === "componentDecl"));
|
||||||
|
assert.equal(formatQx(text), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("component and presentation contracts reject wrong subjects, duplicates and unavailable runtimes", () => {
|
||||||
|
for (const [input, message] of [
|
||||||
|
['component Card id "component:card" subject Missing runtime react;', /Missing/],
|
||||||
|
['component Card id "component:card" subject Data runtime arduino;', /Unsupported component runtime/],
|
||||||
|
[
|
||||||
|
'component Card id "component:card" subject Data runtime react; component Other id "component:card" subject Data runtime react;',
|
||||||
|
/Duplicate component/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'component Card id "component:card" subject Data runtime react; component Card id "component:other" subject Data runtime react;',
|
||||||
|
/Duplicate component/,
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const result = compilePackage(input);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(JSON.stringify(result.diagnostics), message);
|
||||||
|
}
|
||||||
|
for (const [input, message] of [
|
||||||
|
['presentation Card id "presentation:card" for Other using UI.Card;', /Component requires/],
|
||||||
|
['presentation Card id "presentation:card" for Data using UI.Missing;', /Unknown component/],
|
||||||
|
[
|
||||||
|
'presentation Card id "presentation:card" for Data using UI.Card; presentation Duplicate id "presentation:card" for Data using UI.Card;',
|
||||||
|
/Duplicate presentation/,
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const result = workspace(input);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(JSON.stringify(result.diagnostics), message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("model validation rechecks presentation metadata instead of trusting the source parser", () => {
|
||||||
|
const result = workspace();
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
result.workspace.presentations![0]!.componentId = "missing";
|
||||||
|
assert.ok(validateWorkspaceRevision(result.workspace).some((issue) => issue.code === "invalid-presentation"));
|
||||||
|
assert.throws(() => compileExecutionPlan(result.workspace), /no installed package component/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checked component-only artifacts install without a service and reject missing or substituted entries", async () => {
|
||||||
|
const result = workspace();
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
const plan = compileExecutionPlan(result.workspace),
|
||||||
|
pkg = plan.definition.packages[0]!;
|
||||||
|
const bytes = new TextEncoder().encode("export const Card = () => null;\n");
|
||||||
|
const paths = [
|
||||||
|
"components/card.mjs",
|
||||||
|
...componentSharedModules.map((_name, i) => `share/quixos/shared/runtime-${i}.mjs`),
|
||||||
|
];
|
||||||
|
const files = paths.map((file) => ({
|
||||||
|
path: file,
|
||||||
|
digest: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
bytes: bytes.length,
|
||||||
|
mediaType: "text/javascript",
|
||||||
|
}));
|
||||||
|
const body: Omit<ComponentArtifact, "contentDigest"> = {
|
||||||
|
id: "component:card",
|
||||||
|
subject: "interface:Data@1",
|
||||||
|
compatibility: "quixos-react-component-v1",
|
||||||
|
path: paths[0]!,
|
||||||
|
exportName: "Card",
|
||||||
|
files,
|
||||||
|
sharedModules: Object.fromEntries(
|
||||||
|
componentSharedModules.map((name, i) => [name, paths[i + 1]!]),
|
||||||
|
) as ComponentArtifact["sharedModules"],
|
||||||
|
};
|
||||||
|
const manifest: PackageArtifacts = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
packageRevisionId: pkg.revision,
|
||||||
|
bindingDigest: pkg.bindingDigest,
|
||||||
|
sourceDigest: pkg.sourceDigest,
|
||||||
|
versions: plan.definition.versions,
|
||||||
|
implementations: [],
|
||||||
|
components: [{ ...body, contentDigest: componentArtifactDigest(body) }],
|
||||||
|
};
|
||||||
|
const world = await checkExecutionWorld(plan, [manifest], async () => bytes);
|
||||||
|
assert.equal(world.artifacts.length, 1);
|
||||||
|
assert.equal(world.artifacts[0]!.target, "browser-renderer");
|
||||||
|
await assert.rejects(
|
||||||
|
checkExecutionWorld(plan, [{ ...manifest, components: [] }], async () => bytes),
|
||||||
|
/Missing component artifact/,
|
||||||
|
);
|
||||||
|
const substitute = structuredClone(manifest);
|
||||||
|
substitute.components![0]!.subject = "interface:Other@1";
|
||||||
|
const { contentDigest: _digest, ...changed } = substitute.components![0]!;
|
||||||
|
substitute.components![0]!.contentDigest = componentArtifactDigest(changed);
|
||||||
|
await assert.rejects(
|
||||||
|
checkExecutionWorld(plan, [substitute], async () => bytes),
|
||||||
|
/declared subject/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
checkExecutionWorld(plan, [manifest], async () => new Uint8Array(bytes.length)),
|
||||||
|
/Artifact bytes/,
|
||||||
|
);
|
||||||
|
const missingShared = structuredClone(manifest);
|
||||||
|
delete (missingShared.components![0]!.sharedModules as Partial<ComponentArtifact["sharedModules"]>).react;
|
||||||
|
await assert.rejects(
|
||||||
|
checkExecutionWorld(plan, [missingShared], async () => bytes),
|
||||||
|
/missing fields/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "camino-component-contract-"));
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
await mkdir(path.dirname(path.join(root, file.path)), { recursive: true });
|
||||||
|
await writeFile(path.join(root, file.path), bytes);
|
||||||
|
}
|
||||||
|
await mkdir(path.join(root, "share/quixos"), { recursive: true });
|
||||||
|
await writeFile(path.join(root, "share/quixos/package-artifacts.json"), JSON.stringify(manifest));
|
||||||
|
const generator = "a".repeat(64);
|
||||||
|
const { receipt, sources } = expectedArtifactReceipt(plan, manifest, generator);
|
||||||
|
for (const [file, content] of sources) {
|
||||||
|
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
||||||
|
await writeFile(path.join(root, file), content);
|
||||||
|
}
|
||||||
|
await writeFile(path.join(root, "share/quixos/build-receipt.json"), JSON.stringify(receipt));
|
||||||
|
const installation = await loadExecutionInstallation(plan, { [pkg.revision]: root }, generator);
|
||||||
|
assert.equal(installation.packages[0]!.service, undefined);
|
||||||
|
assert.equal(installation.components[0]!.id, "component:card");
|
||||||
|
assert.ok(installation.routes[installation.components[0]!.url]);
|
||||||
|
assert.equal(JSON.stringify(installation.components).includes(root), false);
|
||||||
|
assert.equal(JSON.stringify(installation.components).includes("slot:"), false);
|
||||||
|
const catalog = clientContractCatalog(plan, installation, [pkg.revision]);
|
||||||
|
assert.equal(catalog.presentations[0]!.component, "component:card");
|
||||||
|
assert.equal(catalog.components[0]!.subject, "interface:Data@1");
|
||||||
|
assert.equal(JSON.stringify(catalog).includes(root), false);
|
||||||
|
assert.equal(JSON.stringify(catalog).includes("bindingSchema"), false);
|
||||||
|
assert.throws(() => clientContractCatalog(plan, installation, ["absent"]), /UNINSTALLED_PACKAGE/);
|
||||||
|
assert.throws(
|
||||||
|
() => clientContractCatalog(plan, { ...installation, plan: "bad" }, [pkg.revision]),
|
||||||
|
/CATALOG_PLAN_MISMATCH/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user