create-qpq-app 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/README.md +4 -0
- package/template/apps/qpqjs/account.qpq.ts +4 -0
- package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
- package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
- package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
- package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
- package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
- package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
- package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt-stream.md +2 -2
- package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -0
- package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
- package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
- package/template/docusaurus/docs/actions/core/system/ask-get-runtime-remaining-time.md +52 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
- package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
- package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
- package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
- package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
- package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
- package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
- package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
- package/template/docusaurus/docs/config/core/storage-drive.md +6 -4
- package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
- package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
- package/template/docusaurus/docs/config/features/event-doc.md +2 -1
- package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
- package/template/docusaurus/docs/config/features/tenant.md +36 -12
- package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
- package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
- package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
- package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
- package/template/scripts/smoke/runSmokeRun.mjs +1 -1
- package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { askDnsResolveHosts, askEmailSendEmail, AskResponse } from 'quidproquo';
|
|
2
|
+
|
|
3
|
+
import { smokeEmailMarkerId } from '../../email/smokeEmailMarkerId';
|
|
4
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
5
|
+
import { askSmokePollForMarker } from '../askSmokePollForMarker';
|
|
6
|
+
|
|
7
|
+
// The inbound email path end to end: send to this app's own receiving domain
|
|
8
|
+
// with its own sender, SES receives it through the account rule set and the
|
|
9
|
+
// app's rule, the receiver parses it and hands it to onSmokeEmailReceived,
|
|
10
|
+
// which writes the marker. Deployed only: nothing receives mail locally.
|
|
11
|
+
export function* askRunEmailReceiveTest(runId: string): AskResponse<void> {
|
|
12
|
+
const [receivingHost] = yield* askDnsResolveHosts({ subdomain: 'inbox' });
|
|
13
|
+
const [siteHost] = yield* askDnsResolveHosts();
|
|
14
|
+
|
|
15
|
+
const recipient = `${runId}@${receivingHost}`;
|
|
16
|
+
const subject = `smoke ${runId}`;
|
|
17
|
+
|
|
18
|
+
yield* askEmailSendEmail({
|
|
19
|
+
from: `smoke@${siteHost}`,
|
|
20
|
+
to: [recipient],
|
|
21
|
+
subject,
|
|
22
|
+
bodyText: `smoke probe ${runId}`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Mail delivery is the slowest path in the suite: SES receives, scans and
|
|
26
|
+
// writes to S3 before the drive event fires.
|
|
27
|
+
const marker = yield* askSmokePollForMarker(
|
|
28
|
+
smokeEmailMarkerId(recipient),
|
|
29
|
+
'the inbound email path',
|
|
30
|
+
45
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
yield* askSmokeAssert(
|
|
34
|
+
marker.path === subject,
|
|
35
|
+
`received subject [${marker.path}] did not match [${subject}]`
|
|
36
|
+
);
|
|
37
|
+
yield* askSmokeAssert(
|
|
38
|
+
marker.authentication === 'pass/pass',
|
|
39
|
+
`received message was not authenticated: spf/dkim [${marker.authentication}]`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunEmailReceiveTest';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askFileDelete,
|
|
3
|
+
askFileReadTextContents,
|
|
4
|
+
askFileWriteTextContents,
|
|
5
|
+
askKeyValueStoreDelete,
|
|
6
|
+
askKeyValueStoreGet,
|
|
7
|
+
askKeyValueStoreUpsert,
|
|
8
|
+
askNewGuid,
|
|
9
|
+
AskResponse,
|
|
10
|
+
} from 'quidproquo';
|
|
11
|
+
|
|
12
|
+
import { SmokeProbeRecord } from '@qpqjs/test-models';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
SMOKE_ENCRYPTED_PROBE_DRIVE,
|
|
16
|
+
SMOKE_ENCRYPTED_PROBE_STORE,
|
|
17
|
+
} from '../../constants/smokeProbe';
|
|
18
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
19
|
+
|
|
20
|
+
// A drive and a store encrypted with a defineCryptoKey. Deployed, every write
|
|
21
|
+
// needs kms:GenerateDataKey and every read kms:Decrypt on that key through
|
|
22
|
+
// the alias-conditioned grant, so a plain round trip on each is the proof.
|
|
23
|
+
// Locally there is no encryption at rest and this is a plain round trip.
|
|
24
|
+
export function* askRunEncryptedResourcesTest(): AskResponse<void> {
|
|
25
|
+
const probeId = yield* askNewGuid();
|
|
26
|
+
|
|
27
|
+
const record: SmokeProbeRecord = { probeId, category: 'encrypted', value: 1 };
|
|
28
|
+
yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
|
|
29
|
+
SMOKE_ENCRYPTED_PROBE_STORE,
|
|
30
|
+
record
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
|
|
34
|
+
SMOKE_ENCRYPTED_PROBE_STORE,
|
|
35
|
+
probeId
|
|
36
|
+
);
|
|
37
|
+
yield* askSmokeAssert(
|
|
38
|
+
written?.value === 1,
|
|
39
|
+
'get after upsert on the encrypted store did not return the record'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
yield* askKeyValueStoreDelete(SMOKE_ENCRYPTED_PROBE_STORE, probeId);
|
|
43
|
+
|
|
44
|
+
const filepath = `encrypted/${probeId}.txt`;
|
|
45
|
+
const contents = `encrypted smoke probe ${probeId}`;
|
|
46
|
+
|
|
47
|
+
yield* askFileWriteTextContents(
|
|
48
|
+
SMOKE_ENCRYPTED_PROBE_DRIVE,
|
|
49
|
+
filepath,
|
|
50
|
+
contents
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const readBack = yield* askFileReadTextContents(
|
|
54
|
+
SMOKE_ENCRYPTED_PROBE_DRIVE,
|
|
55
|
+
filepath
|
|
56
|
+
);
|
|
57
|
+
yield* askSmokeAssert(
|
|
58
|
+
readBack === contents,
|
|
59
|
+
'file on the encrypted drive did not read back as written'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
yield* askFileDelete(SMOKE_ENCRYPTED_PROBE_DRIVE, [filepath]);
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunEncryptedResourcesTest';
|
package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts
CHANGED
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
-
askDelay,
|
|
3
2
|
askEventBusSendMessages,
|
|
4
|
-
askKeyValueStoreDelete,
|
|
5
|
-
askKeyValueStoreGet,
|
|
6
3
|
askNewGuid,
|
|
7
4
|
AskResponse,
|
|
8
5
|
EventBusMessage,
|
|
9
|
-
Nullable,
|
|
10
6
|
} from 'quidproquo';
|
|
11
7
|
|
|
12
|
-
import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
|
|
13
|
-
import { SmokeProbeRecord } from '@qpqjs/test-models';
|
|
14
|
-
|
|
15
8
|
import {
|
|
16
9
|
SMOKE_PROBE_EVENT_BUS,
|
|
17
10
|
SMOKE_PROBE_EVENT_TYPE,
|
|
18
11
|
} from '../../constants/smokeProbe';
|
|
19
12
|
import { SmokeProbeEventPayload } from '../../models/SmokeProbeEventQueueEvent';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const POLL_ATTEMPTS = 15;
|
|
23
|
-
const POLL_INTERVAL_MS = 2000;
|
|
13
|
+
import { askSmokePollForMarker } from '../askSmokePollForMarker';
|
|
24
14
|
|
|
25
15
|
// The full async fan-out / fan-in path: publish to the owned event bus, the
|
|
26
16
|
// subscribed queue's entry (onSmokeProbeEvent) writes a marker row, and this
|
|
@@ -39,19 +29,5 @@ export function* askRunEventBusTest(): AskResponse<void> {
|
|
|
39
29
|
eventBusMessages: [message],
|
|
40
30
|
});
|
|
41
31
|
|
|
42
|
-
|
|
43
|
-
for (let attempt = 0; attempt < POLL_ATTEMPTS && !marker; attempt += 1) {
|
|
44
|
-
yield* askDelay(POLL_INTERVAL_MS);
|
|
45
|
-
marker = yield* askKeyValueStoreGet<SmokeProbeRecord>(
|
|
46
|
-
SMOKE_PROBE_STORE,
|
|
47
|
-
markerId
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
yield* askSmokeAssert(
|
|
52
|
-
!!marker,
|
|
53
|
-
`marker [${markerId}] never arrived via the event bus and queue`
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
yield* askKeyValueStoreDelete(SMOKE_PROBE_STORE, markerId);
|
|
32
|
+
yield* askSmokePollForMarker(markerId, 'the event bus and queue');
|
|
57
33
|
}
|
|
@@ -3,7 +3,8 @@ import { askEventDocEventListAll, EventDocEvent } from 'quidproquo-features';
|
|
|
3
3
|
|
|
4
4
|
import { askSmokeAssert } from '../askSmokeAssert';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Long enough to cover a writer's retry laps plus one queue redelivery.
|
|
7
|
+
const POLL_ATTEMPTS = 45;
|
|
7
8
|
const POLL_INTERVAL_MS = 2000;
|
|
8
9
|
|
|
9
10
|
// Poll a probe doc's log until it holds `expectedLength` events (INIT_STATE included),
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export * from './askSmokeAssert';
|
|
2
|
+
export * from './askSmokePollForMarker';
|
|
2
3
|
export * from './crossService';
|
|
4
|
+
export * from './emailReceive';
|
|
5
|
+
export * from './encryptedResources';
|
|
3
6
|
export * from './eventBus';
|
|
4
7
|
export * from './eventDoc';
|
|
5
8
|
export * from './keyValueStore';
|
|
9
|
+
export * from './keyValueStoreStream';
|
|
6
10
|
export * from './noop';
|
|
7
11
|
export * from './openApi';
|
|
8
12
|
export * from './parameter';
|
|
@@ -11,3 +15,4 @@ export * from './signingKey';
|
|
|
11
15
|
export * from './SmokeTestDefinition';
|
|
12
16
|
export * from './smokeTestRegistry';
|
|
13
17
|
export * from './storageDrive';
|
|
18
|
+
export * from './storageDriveEvent';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askKeyValueStoreDelete,
|
|
3
|
+
askKeyValueStoreUpsert,
|
|
4
|
+
askNewGuid,
|
|
5
|
+
AskResponse,
|
|
6
|
+
KvsStreamEventType,
|
|
7
|
+
} from 'quidproquo';
|
|
8
|
+
|
|
9
|
+
import { SmokeProbeRecord } from '@qpqjs/test-models';
|
|
10
|
+
|
|
11
|
+
import { SMOKE_STREAM_PROBE_STORE } from '../../constants/smokeProbe';
|
|
12
|
+
import { smokeStreamMarkerId } from '../../kvsStream/smokeStreamMarkerId';
|
|
13
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
14
|
+
import { askSmokePollForMarker } from '../askSmokePollForMarker';
|
|
15
|
+
|
|
16
|
+
// The kvs stream path end to end on a scoped store: an upsert streams an
|
|
17
|
+
// Insert and a delete streams a Remove to the handler. On dynamo the scope is
|
|
18
|
+
// composed into the stored partition key, so the record must hand it back as
|
|
19
|
+
// its own field with the key stripped, the same as the dev server does.
|
|
20
|
+
export function* askRunKeyValueStoreStreamTest(): AskResponse<void> {
|
|
21
|
+
const probeId = yield* askNewGuid();
|
|
22
|
+
const scope = `smoke-st-${probeId}`;
|
|
23
|
+
|
|
24
|
+
const record: SmokeProbeRecord = {
|
|
25
|
+
probeId,
|
|
26
|
+
category: 'stream',
|
|
27
|
+
value: 1,
|
|
28
|
+
};
|
|
29
|
+
yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
|
|
30
|
+
SMOKE_STREAM_PROBE_STORE,
|
|
31
|
+
record,
|
|
32
|
+
{ scope }
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const inserted = yield* askSmokePollForMarker(
|
|
36
|
+
smokeStreamMarkerId(probeId, KvsStreamEventType.Insert),
|
|
37
|
+
'the kvs stream insert'
|
|
38
|
+
);
|
|
39
|
+
yield* askSmokeAssert(
|
|
40
|
+
inserted.scope === scope,
|
|
41
|
+
`stream insert carried scope [${inserted.scope}], expected [${scope}]`
|
|
42
|
+
);
|
|
43
|
+
yield* askSmokeAssert(
|
|
44
|
+
inserted.path === probeId,
|
|
45
|
+
`stream insert key [${inserted.path}] was not the raw key`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
yield* askKeyValueStoreDelete(SMOKE_STREAM_PROBE_STORE, probeId, undefined, {
|
|
49
|
+
scope,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const removed = yield* askSmokePollForMarker(
|
|
53
|
+
smokeStreamMarkerId(probeId, KvsStreamEventType.Remove),
|
|
54
|
+
'the kvs stream remove'
|
|
55
|
+
);
|
|
56
|
+
yield* askSmokeAssert(
|
|
57
|
+
removed.scope === scope && removed.path === probeId,
|
|
58
|
+
'stream remove did not carry the scope and raw key'
|
|
59
|
+
);
|
|
60
|
+
}
|
package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunKeyValueStoreStreamTest';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askCatch,
|
|
3
|
+
askKeyValueStoreDelete,
|
|
4
|
+
askKeyValueStoreGet,
|
|
5
|
+
askKeyValueStoreGetBase,
|
|
6
|
+
askKeyValueStoreQuery,
|
|
7
|
+
askKeyValueStoreUpsert,
|
|
8
|
+
askKeyValueStoreUpsertBase,
|
|
9
|
+
askNewGuid,
|
|
10
|
+
AskResponse,
|
|
11
|
+
kvsEqual,
|
|
12
|
+
} from 'quidproquo';
|
|
13
|
+
|
|
14
|
+
import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
|
|
15
|
+
import { SmokeProbeRecord } from '@qpqjs/test-models';
|
|
16
|
+
|
|
17
|
+
import { SMOKE_SCOPED_PROBE_STORE } from '../../constants/smokeProbe';
|
|
18
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
19
|
+
|
|
20
|
+
// The scope gate on a store, on whichever backend is running: a scoped store
|
|
21
|
+
// serves scoped calls (including a query) and keeps two scopes apart,
|
|
22
|
+
// refuses an unscoped call, and an unscoped store refuses a scoped one. On
|
|
23
|
+
// dynamo the scope is composed into the partition key, so the read-back also
|
|
24
|
+
// proves the composed form never leaks to the caller.
|
|
25
|
+
export function* askRunScopedKeyValueStoreTest(): AskResponse<void> {
|
|
26
|
+
const probeId = yield* askNewGuid();
|
|
27
|
+
const scopeA = `smoke-a-${probeId}`;
|
|
28
|
+
const scopeB = `smoke-b-${probeId}`;
|
|
29
|
+
const category = `scoped-kvs-${probeId}`;
|
|
30
|
+
|
|
31
|
+
const record: SmokeProbeRecord = { probeId, category, value: 1 };
|
|
32
|
+
yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
|
|
33
|
+
SMOKE_SCOPED_PROBE_STORE,
|
|
34
|
+
record,
|
|
35
|
+
{ scope: scopeA }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
|
|
39
|
+
SMOKE_SCOPED_PROBE_STORE,
|
|
40
|
+
probeId,
|
|
41
|
+
{ scope: scopeA }
|
|
42
|
+
);
|
|
43
|
+
yield* askSmokeAssert(
|
|
44
|
+
written?.probeId === probeId && written.value === 1,
|
|
45
|
+
'scoped get did not return the record with its raw key'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// A scoped query must constrain the partition key (the scope is composed
|
|
49
|
+
// into it on dynamo), so the query is on probeId, not the category index.
|
|
50
|
+
const byKey = yield* askKeyValueStoreQuery<SmokeProbeRecord>(
|
|
51
|
+
SMOKE_SCOPED_PROBE_STORE,
|
|
52
|
+
kvsEqual('probeId', probeId),
|
|
53
|
+
{ scope: scopeA }
|
|
54
|
+
);
|
|
55
|
+
yield* askSmokeAssert(
|
|
56
|
+
byKey.items.some((item) => item.category === category),
|
|
57
|
+
'scoped query by partition key did not return the record'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const fromB = yield* askKeyValueStoreGet<SmokeProbeRecord>(
|
|
61
|
+
SMOKE_SCOPED_PROBE_STORE,
|
|
62
|
+
probeId,
|
|
63
|
+
{ scope: scopeB }
|
|
64
|
+
);
|
|
65
|
+
yield* askSmokeAssert(
|
|
66
|
+
!fromB,
|
|
67
|
+
'record written under scope A is visible from scope B'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const unscopedGet = yield* askCatch(
|
|
71
|
+
askKeyValueStoreGet<SmokeProbeRecord>(SMOKE_SCOPED_PROBE_STORE, probeId)
|
|
72
|
+
);
|
|
73
|
+
yield* askSmokeAssert(
|
|
74
|
+
!unscopedGet.success &&
|
|
75
|
+
unscopedGet.error.errorType ===
|
|
76
|
+
askKeyValueStoreGetBase.errorType.InvalidScope,
|
|
77
|
+
'unscoped get on the scoped store was not refused with InvalidScope'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const scopedUpsertOnUnscopedStore = yield* askCatch(
|
|
81
|
+
askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, record, {
|
|
82
|
+
scope: scopeA,
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
yield* askSmokeAssert(
|
|
86
|
+
!scopedUpsertOnUnscopedStore.success &&
|
|
87
|
+
scopedUpsertOnUnscopedStore.error.errorType ===
|
|
88
|
+
askKeyValueStoreUpsertBase.errorType.InvalidScope,
|
|
89
|
+
'scoped upsert on the unscoped store was not refused with InvalidScope'
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
yield* askKeyValueStoreDelete(SMOKE_SCOPED_PROBE_STORE, probeId, undefined, {
|
|
93
|
+
scope: scopeA,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const deleted = yield* askKeyValueStoreGet<SmokeProbeRecord>(
|
|
97
|
+
SMOKE_SCOPED_PROBE_STORE,
|
|
98
|
+
probeId,
|
|
99
|
+
{ scope: scopeA }
|
|
100
|
+
);
|
|
101
|
+
yield* askSmokeAssert(!deleted, 'scoped record still present after delete');
|
|
102
|
+
}
|
package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunScopedKeyValueStoreTest';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askCatch,
|
|
3
|
+
askFileDelete,
|
|
4
|
+
askFileExists,
|
|
5
|
+
askFileListDirectory,
|
|
6
|
+
askFileReadTextContents,
|
|
7
|
+
askFileWriteTextContents,
|
|
8
|
+
askNewGuid,
|
|
9
|
+
AskResponse,
|
|
10
|
+
} from 'quidproquo';
|
|
11
|
+
|
|
12
|
+
import { SMOKE_PROBE_DRIVE } from '@qpqjs/constants';
|
|
13
|
+
|
|
14
|
+
import { SMOKE_SCOPED_PROBE_DRIVE } from '../../constants/smokeProbe';
|
|
15
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
16
|
+
|
|
17
|
+
// The scope gate on a drive, on whichever backend is running: a scoped drive
|
|
18
|
+
// serves scoped calls and keeps two scopes apart, refuses an unscoped call,
|
|
19
|
+
// and an unscoped drive refuses a scoped one. Each refusal must be the typed
|
|
20
|
+
// InvalidScope error, not a generic failure.
|
|
21
|
+
export function* askRunScopedStorageDriveTest(): AskResponse<void> {
|
|
22
|
+
const fileId = yield* askNewGuid();
|
|
23
|
+
const scopeA = `smoke-a-${fileId}`;
|
|
24
|
+
const scopeB = `smoke-b-${fileId}`;
|
|
25
|
+
const folder = 'scoped';
|
|
26
|
+
const filepath = `${folder}/${fileId}.txt`;
|
|
27
|
+
const contents = `scoped smoke probe ${fileId}`;
|
|
28
|
+
|
|
29
|
+
yield* askFileWriteTextContents(
|
|
30
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
31
|
+
filepath,
|
|
32
|
+
contents,
|
|
33
|
+
undefined,
|
|
34
|
+
scopeA
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const readBack = yield* askFileReadTextContents(
|
|
38
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
39
|
+
filepath,
|
|
40
|
+
scopeA
|
|
41
|
+
);
|
|
42
|
+
yield* askSmokeAssert(
|
|
43
|
+
readBack === contents,
|
|
44
|
+
'scoped file contents did not read back as written'
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const listingA = yield* askFileListDirectory(
|
|
48
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
49
|
+
folder,
|
|
50
|
+
undefined,
|
|
51
|
+
undefined,
|
|
52
|
+
scopeA
|
|
53
|
+
);
|
|
54
|
+
yield* askSmokeAssert(
|
|
55
|
+
listingA.fileInfos.some((info) => info.filepath === filepath),
|
|
56
|
+
'scoped listing does not show the file under its own scope'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const existsInB = yield* askFileExists(
|
|
60
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
61
|
+
filepath,
|
|
62
|
+
scopeB
|
|
63
|
+
);
|
|
64
|
+
yield* askSmokeAssert(
|
|
65
|
+
!existsInB,
|
|
66
|
+
'file written under scope A is visible from scope B'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const unscopedRead = yield* askCatch(
|
|
70
|
+
askFileReadTextContents(SMOKE_SCOPED_PROBE_DRIVE, filepath)
|
|
71
|
+
);
|
|
72
|
+
yield* askSmokeAssert(
|
|
73
|
+
!unscopedRead.success &&
|
|
74
|
+
unscopedRead.error.errorType ===
|
|
75
|
+
askFileReadTextContents.errorType.InvalidScope,
|
|
76
|
+
'unscoped read on the scoped drive was not refused with InvalidScope'
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const unscopedList = yield* askCatch(
|
|
80
|
+
askFileListDirectory(SMOKE_SCOPED_PROBE_DRIVE, folder)
|
|
81
|
+
);
|
|
82
|
+
yield* askSmokeAssert(
|
|
83
|
+
!unscopedList.success &&
|
|
84
|
+
unscopedList.error.errorType ===
|
|
85
|
+
askFileListDirectory.errorType.InvalidScope,
|
|
86
|
+
'unscoped listing on the scoped drive was not refused with InvalidScope'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const scopedWriteOnUnscopedDrive = yield* askCatch(
|
|
90
|
+
askFileWriteTextContents(
|
|
91
|
+
SMOKE_PROBE_DRIVE,
|
|
92
|
+
filepath,
|
|
93
|
+
contents,
|
|
94
|
+
undefined,
|
|
95
|
+
scopeA
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
yield* askSmokeAssert(
|
|
99
|
+
!scopedWriteOnUnscopedDrive.success &&
|
|
100
|
+
scopedWriteOnUnscopedDrive.error.errorType ===
|
|
101
|
+
askFileWriteTextContents.errorType.InvalidScope,
|
|
102
|
+
'scoped write on the unscoped drive was not refused with InvalidScope'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scopeA);
|
|
106
|
+
|
|
107
|
+
const existsAfterDelete = yield* askFileExists(
|
|
108
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
109
|
+
filepath,
|
|
110
|
+
scopeA
|
|
111
|
+
);
|
|
112
|
+
yield* askSmokeAssert(
|
|
113
|
+
!existsAfterDelete,
|
|
114
|
+
'scoped file still exists after delete'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunScopedStorageDriveTest';
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { askRunCrossServiceKeyValueStoreTest } from './crossService/askRunCrossServiceKeyValueStoreTest';
|
|
2
2
|
import { askRunCrossServiceSigningKeyTest } from './crossService/askRunCrossServiceSigningKeyTest';
|
|
3
3
|
import { askRunCrossServiceStorageDriveTest } from './crossService/askRunCrossServiceStorageDriveTest';
|
|
4
|
+
import { askRunEmailReceiveTest } from './emailReceive/askRunEmailReceiveTest';
|
|
5
|
+
import { askRunEncryptedResourcesTest } from './encryptedResources/askRunEncryptedResourcesTest';
|
|
4
6
|
import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
|
|
5
7
|
import { askRunEventDocConcurrentAppendTest } from './eventDoc/askRunEventDocConcurrentAppendTest';
|
|
6
8
|
import { askRunEventDocInterleavedBatchAppendTest } from './eventDoc/askRunEventDocInterleavedBatchAppendTest';
|
|
7
9
|
import { askRunKeyValueStoreTest } from './keyValueStore/askRunKeyValueStoreTest';
|
|
10
|
+
import { askRunKeyValueStoreStreamTest } from './keyValueStoreStream/askRunKeyValueStoreStreamTest';
|
|
8
11
|
import { askRunNoopTest } from './noop/askRunNoopTest';
|
|
9
12
|
import { askRunEchoRoundTripTest } from './openApi/askRunEchoRoundTripTest';
|
|
10
13
|
import { askRunOpenApiDocumentTest } from './openApi/askRunOpenApiDocumentTest';
|
|
11
14
|
import { askRunParameterTest } from './parameter/askRunParameterTest';
|
|
12
15
|
import { askRunScheduleTest } from './schedule/askRunScheduleTest';
|
|
16
|
+
import { askRunScopedKeyValueStoreTest } from './scopedKeyValueStore/askRunScopedKeyValueStoreTest';
|
|
17
|
+
import { askRunScopedStorageDriveTest } from './scopedStorageDrive/askRunScopedStorageDriveTest';
|
|
13
18
|
import { askRunSecretTest } from './secret/askRunSecretTest';
|
|
14
19
|
import { askRunSigningKeyTest } from './signingKey/askRunSigningKeyTest';
|
|
15
20
|
import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
|
|
21
|
+
import { askRunStorageDriveEventTest } from './storageDriveEvent/askRunStorageDriveEventTest';
|
|
16
22
|
import { SmokeTestDefinition } from './SmokeTestDefinition';
|
|
17
23
|
|
|
18
24
|
// Every smoke test a run executes. They run in parallel, one queue message
|
|
@@ -51,4 +57,10 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
|
|
|
51
57
|
name: 'crossServiceSigningKey',
|
|
52
58
|
askRun: askRunCrossServiceSigningKeyTest,
|
|
53
59
|
},
|
|
60
|
+
{ name: 'scopedStorageDrive', askRun: askRunScopedStorageDriveTest },
|
|
61
|
+
{ name: 'scopedKeyValueStore', askRun: askRunScopedKeyValueStoreTest },
|
|
62
|
+
{ name: 'storageDriveEvent', askRun: askRunStorageDriveEventTest },
|
|
63
|
+
{ name: 'keyValueStoreStream', askRun: askRunKeyValueStoreStreamTest },
|
|
64
|
+
{ name: 'encryptedResources', askRun: askRunEncryptedResourcesTest },
|
|
65
|
+
{ name: 'emailReceive', askRun: askRunEmailReceiveTest, deployedOnly: true },
|
|
54
66
|
];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askFileDelete,
|
|
3
|
+
askFileWriteTextContents,
|
|
4
|
+
askNewGuid,
|
|
5
|
+
AskResponse,
|
|
6
|
+
StorageDriveEventType,
|
|
7
|
+
} from 'quidproquo';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
SMOKE_FILE_EVENT_DRIVE,
|
|
11
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
12
|
+
} from '../../constants/smokeProbe';
|
|
13
|
+
import { smokeFileEventMarkerId } from '../../storageDrive/smokeFileEventMarkerId';
|
|
14
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
15
|
+
import { askSmokePollForMarker } from '../askSmokePollForMarker';
|
|
16
|
+
|
|
17
|
+
// The storage drive event path end to end: a write fires the create handler
|
|
18
|
+
// and a delete fires the delete handler, locally through the file watcher and
|
|
19
|
+
// deployed through the S3 notification. On the scoped drive the event must
|
|
20
|
+
// arrive with the scope as its own field, the path made scope-relative, and
|
|
21
|
+
// the handler running under that scope.
|
|
22
|
+
export function* askRunStorageDriveEventTest(): AskResponse<void> {
|
|
23
|
+
const fileId = yield* askNewGuid();
|
|
24
|
+
const filepath = `events/${fileId}.txt`;
|
|
25
|
+
const scope = `smoke-ev-${fileId}`;
|
|
26
|
+
|
|
27
|
+
yield* askFileWriteTextContents(SMOKE_FILE_EVENT_DRIVE, filepath, fileId);
|
|
28
|
+
|
|
29
|
+
const created = yield* askSmokePollForMarker(
|
|
30
|
+
smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
|
|
31
|
+
'the unscoped drive create event'
|
|
32
|
+
);
|
|
33
|
+
yield* askSmokeAssert(
|
|
34
|
+
created.path === filepath && created.scope === undefined,
|
|
35
|
+
'unscoped create event did not carry the bare path with no scope'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
yield* askFileDelete(SMOKE_FILE_EVENT_DRIVE, [filepath]);
|
|
39
|
+
|
|
40
|
+
yield* askSmokePollForMarker(
|
|
41
|
+
smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
|
|
42
|
+
'the unscoped drive delete event'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
yield* askFileWriteTextContents(
|
|
46
|
+
SMOKE_SCOPED_PROBE_DRIVE,
|
|
47
|
+
filepath,
|
|
48
|
+
fileId,
|
|
49
|
+
undefined,
|
|
50
|
+
scope
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const scopedCreated = yield* askSmokePollForMarker(
|
|
54
|
+
smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
|
|
55
|
+
'the scoped drive create event'
|
|
56
|
+
);
|
|
57
|
+
yield* askSmokeAssert(
|
|
58
|
+
scopedCreated.scope === scope,
|
|
59
|
+
`scoped create event carried scope [${scopedCreated.scope}], expected [${scope}]`
|
|
60
|
+
);
|
|
61
|
+
yield* askSmokeAssert(
|
|
62
|
+
scopedCreated.path === filepath,
|
|
63
|
+
`scoped create event path [${scopedCreated.path}] was not scope-relative`
|
|
64
|
+
);
|
|
65
|
+
yield* askSmokeAssert(
|
|
66
|
+
scopedCreated.ambientScope === scope,
|
|
67
|
+
'scoped create handler did not run under the event scope'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scope);
|
|
71
|
+
|
|
72
|
+
const scopedDeleted = yield* askSmokePollForMarker(
|
|
73
|
+
smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
|
|
74
|
+
'the scoped drive delete event'
|
|
75
|
+
);
|
|
76
|
+
yield* askSmokeAssert(
|
|
77
|
+
scopedDeleted.scope === scope && scopedDeleted.path === filepath,
|
|
78
|
+
'scoped delete event did not carry the scope and scope-relative path'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunStorageDriveEventTest';
|
|
@@ -42,7 +42,7 @@ The parameters are identical to [askAiPrompt](./ask-ai-prompt.md) — see there
|
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `model` | `AiModel` | Which model to prompt. |
|
|
44
44
|
| `prompt` | `string` | The user prompt. Ignored when `options.messages` is set. |
|
|
45
|
-
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
45
|
+
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching?, maxDurationMs?, maxSteps?, maxOutputTokens? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
46
46
|
|
|
47
47
|
## Returns
|
|
48
48
|
|
|
@@ -92,7 +92,7 @@ Within a step, text / reasoning / tool-input events arrive as matched `*Start
|
|
|
92
92
|
| `stop` | `stop` | The model completed its answer naturally. |
|
|
93
93
|
| `length` | `length` | The response hit the output token limit. |
|
|
94
94
|
| `contentFilter` | `content-filter` | The provider's content filter stopped the response. |
|
|
95
|
-
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (
|
|
95
|
+
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (`maxSteps` or `maxDurationMs`) rather than finishing naturally, and can be resumed by re-sending the recorded history. |
|
|
96
96
|
| `error` | `error` | The stream errored. |
|
|
97
97
|
| `other` | `other` | The provider reported a reason outside this catalog. |
|
|
98
98
|
| `unknown` | `unknown` | The provider reported no reason, or one this version does not recognise. |
|
|
@@ -50,6 +50,9 @@ function* askAiPrompt(
|
|
|
50
50
|
| `messages` | [`AiMessage[]`](#aimessage) | – | A full conversation history. When present, this is sent instead of `prompt`, letting you carry a multi-turn dialogue (including prior assistant turns and tool results). |
|
|
51
51
|
| `reasoning` | [`AiReasoningConfig`](#aireasoningconfig) | – | Enables extended thinking. Its presence turns reasoning on; `budgetTokens` caps how many tokens the model may spend thinking before it answers (defaults to `4096` on AWS). |
|
|
52
52
|
| `caching` | `boolean` | – | Marks the system prompt and the last message (or the last `messages` entry) with a Bedrock cache point, so a following call in the same conversation can read everything up to there from cache instead of reprocessing it. |
|
|
53
|
+
| `maxSteps` | `number` | – | Cap on model/tool steps in one call. Unset means no cap: the loop runs until the model stops on its own or `maxDurationMs` trips. A client-side tool call (a tool with no executor) still halts it immediately. |
|
|
54
|
+
| `maxOutputTokens` | `number` | provider default | Output token cap per model call. Bedrock defaults to 8192, which a reasoning block plus a large tool input can exceed; the step then finishes with `length` and the tool call arrives truncated. Raise it for agentic workloads (Claude Sonnet allows 64k). |
|
|
55
|
+
| `maxDurationMs` | `number` | – | Wall-clock budget for the tool loop. Checked between steps, so the loop can overrun by one step; leave headroom. When it trips with tool calls still outstanding the result finishes with `toolCalls`, and re-sending the recorded history resumes the turn. Pair it with [askGetRuntimeRemainingTime](../system/ask-get-runtime-remaining-time.md) to stop before the platform deadline. |
|
|
53
56
|
|
|
54
57
|
### `AiModel`
|
|
55
58
|
|
|
@@ -60,7 +60,7 @@ function* askFileWriteTextContents(
|
|
|
60
60
|
## Notes
|
|
61
61
|
|
|
62
62
|
- If the drive declares an `onEvent.create` handler in [defineStorageDrive](../../../config/core/storage-drive.md#file-events-onevent), that story is triggered by this write.
|
|
63
|
-
-
|
|
63
|
+
- Every drive is encrypted at rest; `cryptoKeyName` swaps in one of your own `defineCryptoKey`s. Nothing changes at the call site either way.
|
|
64
64
|
|
|
65
65
|
## Related
|
|
66
66
|
|