mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+594
@@ -0,0 +1,594 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
assertPluginPackageAutomationPublicationSuccessor,
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize,
|
||||
normalizePluginPackageAutomationPublication,
|
||||
normalizePluginPackageAutomationPublicationRecoveryCursor,
|
||||
type PluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublicationRecoveryPage,
|
||||
type PluginPackageAutomationPublicationRepository,
|
||||
type PluginPackageAutomationPublicationRecoverySource,
|
||||
type PluginPackageAutomationPublicationStartGuard,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(message);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function targetIdentity(projectId: unknown, packageName: unknown): {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
} {
|
||||
if (typeof projectId !== 'string' || !IDENTIFIER.test(projectId)) {
|
||||
return invalid('projectId is invalid');
|
||||
}
|
||||
if (typeof packageName !== 'string' || !PACKAGE_NAME.test(packageName)) {
|
||||
return invalid('packageName is invalid');
|
||||
}
|
||||
return { projectId, packageName };
|
||||
}
|
||||
|
||||
function publicationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('publicationDigest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
publication: Readonly<PluginPackageAutomationPublication>,
|
||||
): string {
|
||||
const value = JSON.stringify(publication);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES
|
||||
) {
|
||||
return invalid('publication exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageAutomationPublicationError ||
|
||||
error instanceof PluginPackageAutomationPublicationConflictError ||
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageAutomationPublicationConflictError(
|
||||
'durable publication chain changed',
|
||||
);
|
||||
}
|
||||
return new PluginPackageAutomationPublicationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageAutomationPublicationRepository
|
||||
implements
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
PluginPackageAutomationPublicationRecoverySource,
|
||||
PluginPackageAutomationPublicationStartGuard
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
#parse(row: Row): Readonly<PluginPackageAutomationPublication> {
|
||||
try {
|
||||
const publication = normalizePluginPackageAutomationPublication(
|
||||
JSON.parse(text(row, 'publicationJson')) as
|
||||
PluginPackageAutomationPublication,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !==
|
||||
text(row, 'publicationDigest') ||
|
||||
publication.target.projectId !== text(row, 'projectId') ||
|
||||
publication.target.packageName !== text(row, 'packageName') ||
|
||||
publication.target.installationId !== text(row, 'installationId') ||
|
||||
publication.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
publication.target.generation !== integer(row, 'generation') ||
|
||||
publication.target.generationDigest !==
|
||||
text(row, 'generationDigest') ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
text(row, 'materializedRevisionDigest') ||
|
||||
publication.state !== text(row, 'state') ||
|
||||
publication.version !== integer(row, 'version') ||
|
||||
publication.publishedAtMs !== integer(row, 'publishedAtMs')
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return publication;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#select(
|
||||
where: string,
|
||||
values: readonly (string | number | null)[],
|
||||
): Row | undefined {
|
||||
return this.#authority.client
|
||||
.prepare(
|
||||
`SELECT publication_digest AS "publicationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
state,
|
||||
version,
|
||||
published_at_ms AS "publishedAtMs",
|
||||
publication_json AS "publicationJson"
|
||||
FROM "QingLong3PluginPackageAutomationPublications"
|
||||
WHERE ${where}`,
|
||||
)
|
||||
.get(...values) as Row | undefined;
|
||||
}
|
||||
|
||||
#findByDigest(
|
||||
digest: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const row = this.#select('publication_digest = ?', [digest]);
|
||||
return row ? this.#parse(row) : null;
|
||||
}
|
||||
|
||||
#findCurrent(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const row = this.#select(
|
||||
`publication_digest = (
|
||||
SELECT publication_digest
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads"
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
)`,
|
||||
[projectId, packageName],
|
||||
);
|
||||
return row ? this.#parse(row) : null;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageAutomationPublicationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
findCurrent(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
return this.#enqueue(() => this.#findCurrent(projectId, packageName));
|
||||
}
|
||||
|
||||
findByDigest(
|
||||
publicationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
return this.#enqueue(() => this.#findByDigest(digest));
|
||||
}
|
||||
|
||||
isStartAllowed(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
publicationDigestValue: string,
|
||||
): Promise<boolean> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
return this.#enqueue(() => {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageAutomationPublicationHeads" AS head
|
||||
JOIN "QingLong3PluginPackageAutomationPublications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
JOIN "QingLong3PluginPackageInstallHeads" AS install_head
|
||||
ON install_head.project_id = publication.project_id
|
||||
AND install_head.package_name = publication.package_name
|
||||
AND install_head.installation_id =
|
||||
publication.installation_id
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
AND install.lock_digest = publication.lock_digest
|
||||
LEFT JOIN "QingLong3PluginPackageLifecycleHeads" AS lifecycle
|
||||
ON lifecycle.project_id = publication.project_id
|
||||
AND lifecycle.package_name = publication.package_name
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND head.publication_digest = ?
|
||||
AND publication.state = 'active'
|
||||
AND install.state = 'active'
|
||||
AND install.active_lock_digest = publication.lock_digest
|
||||
AND (
|
||||
lifecycle.event_digest IS NULL OR
|
||||
lifecycle.disposition = 'active'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = publication.project_id
|
||||
AND quarantine.package_name = publication.package_name
|
||||
AND quarantine.installation_id =
|
||||
publication.installation_id
|
||||
AND quarantine.lock_digest = publication.lock_digest
|
||||
)
|
||||
) AS "allowed"`,
|
||||
)
|
||||
.get(projectId, packageName, digest) as Row | undefined;
|
||||
if (!row || (row.allowed !== 0 && row.allowed !== 1)) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return row.allowed === 1;
|
||||
});
|
||||
}
|
||||
|
||||
listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageAutomationPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
return Promise.reject(
|
||||
new InvalidPluginPackageAutomationPublicationError(
|
||||
'pending page options are invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageAutomationPublicationRecoveryCursor(
|
||||
options.after,
|
||||
);
|
||||
return this.#enqueue(() => {
|
||||
const rows = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT install.project_id AS "projectId",
|
||||
install.package_name AS "packageName"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS install_head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
JOIN "QingLong3PluginPackageMaterializedRevisions" AS revision
|
||||
ON revision.project_id = install.project_id
|
||||
AND revision.package_name = install.package_name
|
||||
AND revision.generation = install.target_generation
|
||||
AND revision.lock_digest = install.lock_digest
|
||||
LEFT JOIN
|
||||
"QingLong3PluginPackageAutomationPublicationHeads" AS publication
|
||||
ON publication.project_id = install.project_id
|
||||
AND publication.package_name = install.package_name
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = install.project_id
|
||||
AND quarantine.package_name = install.package_name
|
||||
AND quarantine.installation_id = install.installation_id
|
||||
AND quarantine.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
publication.generation_digest IS NULL OR
|
||||
publication.generation_digest <> revision.generation_digest
|
||||
)
|
||||
AND (
|
||||
? IS NULL OR install.project_id > ? OR
|
||||
(install.project_id = ? AND install.package_name > ?)
|
||||
)
|
||||
ORDER BY install.project_id, install.package_name
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
) as Row[];
|
||||
const truncated = rows.length > options.limit;
|
||||
const candidates = rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: text(row, 'projectId'),
|
||||
packageName: text(row, 'packageName'),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
findCurrentInTransaction(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Readonly<PluginPackageAutomationPublication> | null {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
if (!this.#authority.client.isTransaction) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
return this.#findCurrent(projectId, packageName);
|
||||
}
|
||||
|
||||
publishInTransaction(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}> {
|
||||
const client = this.#authority.client;
|
||||
if (!client.isTransaction) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
const publication = normalizePluginPackageAutomationPublication(value);
|
||||
const publicationJson = serialize(publication);
|
||||
const existing = this.#findByDigest(publication.publicationDigest);
|
||||
if (existing) {
|
||||
if (serialize(existing) !== publicationJson) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'publication digest is bound to another semantic publication',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: existing,
|
||||
});
|
||||
}
|
||||
const current = this.#findCurrent(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
);
|
||||
if (publication.version === 1) {
|
||||
if (current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'Package already has an automation publication head',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'previous automation publication head is absent',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertPluginPackageAutomationPublicationSuccessor(
|
||||
current,
|
||||
publication,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication does not succeed the current head',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const revision = client
|
||||
.prepare(
|
||||
`SELECT revision_digest AS "revisionDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest"
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(publication.target.generationDigest) as Row | undefined;
|
||||
if (
|
||||
!revision ||
|
||||
text(revision, 'revisionDigest') !==
|
||||
publication.target.materializedRevisionDigest ||
|
||||
text(revision, 'projectId') !== publication.target.projectId ||
|
||||
text(revision, 'packageName') !== publication.target.packageName ||
|
||||
integer(revision, 'generation') !== publication.target.generation ||
|
||||
text(revision, 'lockDigest') !== publication.target.lockDigest
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'materialized revision fence does not match publication target',
|
||||
);
|
||||
}
|
||||
const securityFence = client
|
||||
.prepare(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
WHERE quarantine.project_id = ?
|
||||
AND quarantine.package_name = ?
|
||||
AND quarantine.installation_id = ?
|
||||
AND quarantine.lock_digest = ?
|
||||
) AS "blocked"`,
|
||||
)
|
||||
.get(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
) as Row | undefined;
|
||||
if (
|
||||
!securityFence ||
|
||||
(securityFence.blocked !== 0 && securityFence.blocked !== 1)
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationUnavailableError();
|
||||
}
|
||||
if (securityFence.blocked === 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'quarantined Package generation cannot publish automation',
|
||||
);
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationPublications" (
|
||||
publication_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, state, version,
|
||||
previous_publication_digest, lifecycle_event_digest,
|
||||
published_at_ms, publication_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.publicationDigest,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
publication.target.generation,
|
||||
publication.target.generationDigest,
|
||||
publication.target.materializedRevisionDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.previousPublicationDigest,
|
||||
publication.lifecycleEventDigest,
|
||||
publication.publishedAtMs,
|
||||
publicationJson,
|
||||
);
|
||||
if (!current) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageAutomationPublicationHeads" (
|
||||
project_id, package_name, publication_digest,
|
||||
generation_digest, state, version, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
);
|
||||
} else {
|
||||
const updated = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageAutomationPublicationHeads"
|
||||
SET publication_digest = ?, generation_digest = ?, state = ?,
|
||||
version = ?, updated_at_ms = ?
|
||||
WHERE project_id = ? AND package_name = ?
|
||||
AND publication_digest = ? AND version = ?`,
|
||||
)
|
||||
.run(
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
current.publicationDigest,
|
||||
current.version,
|
||||
);
|
||||
if (updated.changes !== 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication head changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
});
|
||||
}
|
||||
|
||||
publish(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
return this.#enqueue(() => {
|
||||
const client = this.#authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = this.publishInTransaction(value);
|
||||
client.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user