mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-28 08:49:38 +02:00
* feat(setup): drop redundant Snowflake schema prompt; fall back to free-text on listSchemas failure Snowflake setup previously asked for a single schema as free text, then ran a multiselect against the discovered schemas — two schema questions back-to-back, with the first being only a session bootstrap. The SDK's `schema` is optional, so the bootstrap step is unnecessary. - Remove the free-text Snowflake schema prompt; only pass `schema` to snowflake-sdk when one is configured. - When `listSchemas()` fails (e.g. role lacks SHOW SCHEMAS), prompt the user for a comma-separated list, persist it as `schema_names`, and use it as both the table-list filter and the multiselect default. Applies to every driver with a scope-discovery spec, not just Snowflake. - Update docs to lead with `schema_names`; keep `schema_name` as a documented single-schema shorthand. * fix(snowflake): keep introspecting when primary-key discovery is denied The PK query joins INFORMATION_SCHEMA.TABLE_CONSTRAINTS and INFORMATION_SCHEMA.KEY_COLUMN_USAGE, which require grants the connection role may not have. Previously a 'SQL compilation error: Object ANALYTICS.INFORMATION_SCHEMA.KEY_COLUMN_USAGE does not exist or not authorized' aborted the entire introspect — schemas, columns, and row counts were all discarded over a missing nice-to-have. Wrap the constraint query in try/catch, log a one-line warning per schema, and return an empty PK map. Columns end up with primaryKey=false; relationship inference still has FK and profiling to fall back on. * fix(scan): unblock relationship discovery on Snowflake Two adjacent bugs prevented the scan's relationship pipeline from producing any joins on a Snowflake warehouse: - relationship-profiling.ts fell through to a default `GROUP_CONCAT` branch for unknown drivers. Snowflake has no GROUP_CONCAT, so every per-table profile query failed with "Unknown function GROUP_CONCAT". Add an explicit Snowflake branch that uses LISTAGG with a literal '\x1f' delimiter (Snowflake requires the delimiter to be a constant, so CHR(31) is rejected). - description-generation.ts destructured `connector.sampleTable` and `connector.sampleColumn` into bare locals, losing the `this` binding when the class-method connectors (Snowflake, Postgres, MySQL) were invoked. Every sample call threw "Cannot read properties of undefined (reading 'assertConnection')" and degraded LLM descriptions to metadata-only prompts. Call the methods through the connector instead. Without these, even after the primary-key probe is allowed to fail softly, the scan ends up with 0 validated relationships and an empty `joins:` block in every shard YAML. * test(scan): cover table-ref helpers * feat(scan): plumb tableScope through live-database introspection port * feat(scan): apply tableScope during metadata fetch * feat(scan): enforce table scope at fetch boundary * feat(scan): pool Snowflake sessions and batch enrichment for faster ingest (#206) * feat(cli): add RSA key-pair auth option to Snowflake setup wizard Extends the interactive Snowflake setup flow with an authentication-method prompt (password vs RSA/JWT key-pair). The RSA branch collects a private-key path (env/file/absolute) and an optional passphrase; the resulting connection config records `authMethod: 'rsa'` with `privateKey` and `passphrase` instead of `password`. * feat(scan): pool Snowflake sessions * fix(scan): reuse structural snapshots and cleanup connectors * feat(scan): parallelize relationship profiling * feat(scan): batch table description generation * docs: document Snowflake ingest concurrency knobs * fix(scan): close Snowflake ingest perf verification gaps * fix(scan): keep batched description failure bounded * feat(scan): dispatch query-history probes by connection driver Extract historic-sql dialect resolution into a shared helper so the status-project readiness check and the local ingest factory agree on which connections enable query history and which probe to run. The status command now picks the postgres/snowflake/bigquery probe based on the connection's driver instead of always reporting against postgres, which previously caused snowflake connections with queryHistory.enabled to surface a misleading "driver is snowflake" failure. Also drops a noisy console.warn from Snowflake primary-key discovery — INFORMATION_SCHEMA.KEY_COLUMN_USAGE is commonly ungranted for read-only roles and the FK + profiling paths handle the empty PK map already. * fix(llm): allow StructuredOutput tool and raise maxTurns for generateObject The Claude Code agent SDK announces an internal pseudo-tool named StructuredOutput in the system/init message whenever outputFormat is set to { type: 'json_schema' }. The runtime's isolation check built its allowedToolIds set only from MCP tool ids and treated StructuredOutput as an unexpected host-injected tool, so every generateObject call threw "Claude Code runtime isolation failed: tools=StructuredOutput ..." and the table-descriptions and relationship-LLM-proposal enrichment stages recorded null output across the board. Whitelist StructuredOutput specifically in generateObject's allowedToolIds — the check also enforces missing_tools symmetry, so generateText and runAgentLoop, which do not see StructuredOutput, must not require it. generateObject also ran with maxTurns: 1, which the model intermittently breached when it emitted thinking text before the structured response. Raised to 5 to give the schema-bound call enough headroom without allowing unbounded loops. The existing tests now exercise the path with an init message that announces StructuredOutput so the regression cannot slip back in. * chore(scripts): add ktx-reset.sh project-cleanup helper Convenience script for repeatable ingest testing: takes a project directory and prunes everything except ktx.yaml and .ktx/secrets/, so the next ktx setup or ktx ingest run starts from a known-clean state.
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
import YAML from 'yaml';
|
|
import { buildLiveDatabaseManifestShards, type LiveDatabaseManifestExistingDescriptions, type LiveDatabaseManifestJoinData, type LiveDatabaseManifestJoinEntry, type LiveDatabaseManifestShard, type LiveDatabaseManifestTableData } from '../../context/ingest/adapters/live-database/manifest.js';
|
|
import type { TableUsageOutput } from '../../context/ingest/adapters/historic-sql/skill-schemas.js';
|
|
import type { KtxScanRelationshipConfig } from '../project/config.js';
|
|
import type { KtxLocalProject } from '../../context/project/project.js';
|
|
import type { KtxLocalScanEnrichmentResult } from './local-enrichment.js';
|
|
import {
|
|
buildKtxRelationshipArtifacts,
|
|
buildKtxRelationshipDiagnostics,
|
|
emptyKtxRelationshipProfileArtifact,
|
|
} from './relationship-diagnostics.js';
|
|
import type { KtxConnectionDriver, KtxSchemaColumn, KtxSchemaSnapshot, KtxSchemaTable } from './types.js';
|
|
|
|
const LIVE_DATABASE_ADAPTER = 'live-database';
|
|
const LOCAL_AUTHOR = 'ktx';
|
|
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
|
|
const SCHEMA_DIR = '_schema';
|
|
const SL_DIR_PREFIX = 'semantic-layer';
|
|
|
|
export interface WriteLocalScanManifestShardsInput {
|
|
project: KtxLocalProject;
|
|
connectionId: string;
|
|
syncId: string;
|
|
driver: KtxConnectionDriver;
|
|
snapshot: KtxSchemaSnapshot;
|
|
dryRun: boolean;
|
|
descriptionUpdates?: KtxLocalScanEnrichmentResult['descriptionUpdates'];
|
|
relationshipUpdate?: KtxLocalScanEnrichmentResult['relationshipUpdate'];
|
|
}
|
|
|
|
export interface WriteLocalScanManifestShardsResult {
|
|
manifestShards: string[];
|
|
manifestShardsWritten: number;
|
|
}
|
|
|
|
export interface WriteLocalScanEnrichmentArtifactsInput {
|
|
project: KtxLocalProject;
|
|
connectionId: string;
|
|
syncId: string;
|
|
driver: KtxConnectionDriver;
|
|
enrichment: KtxLocalScanEnrichmentResult;
|
|
dryRun: boolean;
|
|
relationshipSettings?: KtxScanRelationshipConfig;
|
|
}
|
|
|
|
export interface WriteLocalScanEnrichmentArtifactsResult extends WriteLocalScanManifestShardsResult {
|
|
enrichmentArtifacts: string[];
|
|
}
|
|
|
|
interface ExistingManifestState {
|
|
descriptions: Map<string, LiveDatabaseManifestExistingDescriptions>;
|
|
preservedJoins: Map<string, LiveDatabaseManifestJoinEntry[]>;
|
|
usage: Map<string, TableUsageOutput>;
|
|
}
|
|
|
|
type LocalDescriptionUpdates = KtxLocalScanEnrichmentResult['descriptionUpdates'];
|
|
|
|
function isGeneratedErrorDescription(description: string | null | undefined): boolean {
|
|
const normalized = description?.trim().toLowerCase();
|
|
return (
|
|
normalized === 'failed to generate description' ||
|
|
normalized?.startsWith('error generating description:') === true
|
|
);
|
|
}
|
|
|
|
function artifactDir(connectionId: string, syncId: string): string {
|
|
return `raw-sources/${connectionId}/${LIVE_DATABASE_ADAPTER}/${syncId}/enrichment`;
|
|
}
|
|
|
|
function schemaDir(connectionId: string): string {
|
|
return `${SL_DIR_PREFIX}/${connectionId}/${SCHEMA_DIR}`;
|
|
}
|
|
|
|
function tableDescription(
|
|
table: KtxSchemaTable,
|
|
descriptionUpdates: LocalDescriptionUpdates = [],
|
|
): Record<string, string> | undefined {
|
|
const update = descriptionUpdates.find((candidate) => candidate.table.name === table.name);
|
|
const descriptions: Record<string, string> = {};
|
|
if (table.comment) {
|
|
descriptions.db = table.comment;
|
|
}
|
|
if (update?.tableDescription && !isGeneratedErrorDescription(update.tableDescription)) {
|
|
descriptions.ai = update.tableDescription;
|
|
}
|
|
return Object.keys(descriptions).length > 0 ? descriptions : undefined;
|
|
}
|
|
|
|
function columnDescription(
|
|
table: KtxSchemaTable,
|
|
column: KtxSchemaColumn,
|
|
descriptionUpdates: LocalDescriptionUpdates = [],
|
|
): Record<string, string> | undefined {
|
|
const update = descriptionUpdates.find((candidate) => candidate.table.name === table.name);
|
|
const aiDescription = update?.columnDescriptions[column.name] ?? null;
|
|
const descriptions: Record<string, string> = {};
|
|
if (column.comment) {
|
|
descriptions.db = column.comment;
|
|
}
|
|
if (aiDescription && !isGeneratedErrorDescription(aiDescription)) {
|
|
descriptions.ai = aiDescription;
|
|
}
|
|
return Object.keys(descriptions).length > 0 ? descriptions : undefined;
|
|
}
|
|
|
|
function snapshotTablesToManifestData(
|
|
snapshot: KtxSchemaSnapshot,
|
|
descriptionUpdates: LocalDescriptionUpdates = [],
|
|
): LiveDatabaseManifestTableData[] {
|
|
return snapshot.tables.map((table) => ({
|
|
name: table.name,
|
|
catalog: table.catalog,
|
|
db: table.db,
|
|
descriptions: tableDescription(table, descriptionUpdates),
|
|
columns: table.columns.map((column) => ({
|
|
name: column.name,
|
|
type: column.dimensionType,
|
|
...(column.primaryKey ? { pk: true } : {}),
|
|
...(column.nullable === false ? { nullable: false } : {}),
|
|
descriptions: columnDescription(table, column, descriptionUpdates),
|
|
})),
|
|
}));
|
|
}
|
|
|
|
function formalJoins(snapshot: KtxSchemaSnapshot): LiveDatabaseManifestJoinData[] {
|
|
const joins: LiveDatabaseManifestJoinData[] = [];
|
|
for (const table of snapshot.tables) {
|
|
for (const foreignKey of table.foreignKeys) {
|
|
joins.push({
|
|
fromTable: table.name,
|
|
fromColumns: [foreignKey.fromColumn],
|
|
toTable: foreignKey.toTable,
|
|
toColumns: [foreignKey.toColumn],
|
|
relationship: 'many_to_one',
|
|
source: 'formal',
|
|
});
|
|
}
|
|
}
|
|
return joins;
|
|
}
|
|
|
|
function acceptedRelationshipJoins(
|
|
relationshipUpdate: KtxLocalScanEnrichmentResult['relationshipUpdate'] | undefined,
|
|
): LiveDatabaseManifestJoinData[] {
|
|
return (relationshipUpdate?.accepted ?? []).map((relationship) => ({
|
|
fromTable: relationship.from.table.name,
|
|
fromColumns: relationship.from.columns,
|
|
toTable: relationship.to.table.name,
|
|
toColumns: relationship.to.columns,
|
|
relationship: relationship.relationshipType,
|
|
source: relationship.source,
|
|
}));
|
|
}
|
|
|
|
function relationshipJoins(
|
|
snapshot: KtxSchemaSnapshot,
|
|
relationshipUpdate: KtxLocalScanEnrichmentResult['relationshipUpdate'] | undefined,
|
|
): LiveDatabaseManifestJoinData[] {
|
|
const accepted = acceptedRelationshipJoins(relationshipUpdate);
|
|
const manual = accepted.filter((relationship) => relationship.source === 'manual');
|
|
const generated = accepted.filter((relationship) => relationship.source !== 'manual');
|
|
return [...manual, ...formalJoins(snapshot), ...generated];
|
|
}
|
|
|
|
function validColumns(snapshot: KtxSchemaSnapshot): Map<string, Set<string>> {
|
|
return new Map(snapshot.tables.map((table) => [table.name, new Set(table.columns.map((column) => column.name))]));
|
|
}
|
|
|
|
function joinReferencesExistingColumns(
|
|
join: LiveDatabaseManifestJoinEntry,
|
|
columnsByTable: Map<string, Set<string>>,
|
|
): boolean {
|
|
const terms = join.on.split(/\s+AND\s+/iu);
|
|
for (const term of terms) {
|
|
const match = term.match(/^(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)$/u);
|
|
if (!match) {
|
|
return true;
|
|
}
|
|
const leftTable = match[1];
|
|
const leftColumn = match[2];
|
|
const rightTable = match[3];
|
|
const rightColumn = match[4];
|
|
if (!leftTable || !leftColumn || !rightTable || !rightColumn) {
|
|
return true;
|
|
}
|
|
const leftColumns = columnsByTable.get(leftTable);
|
|
const rightColumns = columnsByTable.get(rightTable);
|
|
if ((leftColumns && !leftColumns.has(leftColumn)) || (rightColumns && !rightColumns.has(rightColumn))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function loadExistingManifestState(
|
|
project: KtxLocalProject,
|
|
connectionId: string,
|
|
snapshot: KtxSchemaSnapshot,
|
|
): Promise<ExistingManifestState> {
|
|
const descriptions = new Map<string, LiveDatabaseManifestExistingDescriptions>();
|
|
const preservedJoins = new Map<string, LiveDatabaseManifestJoinEntry[]>();
|
|
const usage = new Map<string, TableUsageOutput>();
|
|
const validTableNames = new Set(snapshot.tables.map((table) => table.name));
|
|
const columnsByTable = validColumns(snapshot);
|
|
|
|
let files: string[];
|
|
try {
|
|
files = (await project.fileStore.listFiles(schemaDir(connectionId))).files.filter((file) => file.endsWith('.yaml'));
|
|
} catch {
|
|
return { descriptions, preservedJoins, usage };
|
|
}
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const { content } = await project.fileStore.readFile(file);
|
|
const shard = YAML.parse(content) as LiveDatabaseManifestShard | null;
|
|
if (!shard?.tables) {
|
|
continue;
|
|
}
|
|
for (const [tableName, entry] of Object.entries(shard.tables)) {
|
|
if (!validTableNames.has(tableName)) {
|
|
continue;
|
|
}
|
|
descriptions.set(tableName, {
|
|
table: entry.descriptions ? { ...entry.descriptions } : undefined,
|
|
columns: new Map(
|
|
(entry.columns ?? []).flatMap((column) =>
|
|
column.descriptions ? ([[column.name, { ...column.descriptions }]] as const) : [],
|
|
),
|
|
),
|
|
});
|
|
if (entry.usage) {
|
|
usage.set(tableName, { ...entry.usage });
|
|
}
|
|
const joins = (entry.joins ?? []).filter((join) => {
|
|
return (
|
|
(join.source === 'manual' || join.source === 'inferred') &&
|
|
validTableNames.has(join.to) &&
|
|
joinReferencesExistingColumns(join, columnsByTable)
|
|
);
|
|
});
|
|
if (joins.length > 0) {
|
|
preservedJoins.set(tableName, joins);
|
|
}
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { descriptions, preservedJoins, usage };
|
|
}
|
|
|
|
async function writeJsonArtifact(
|
|
project: KtxLocalProject,
|
|
path: string,
|
|
value: unknown,
|
|
commitMessage: string,
|
|
): Promise<void> {
|
|
await project.fileStore.writeFile(
|
|
path,
|
|
`${JSON.stringify(value, null, 2)}\n`,
|
|
LOCAL_AUTHOR,
|
|
LOCAL_AUTHOR_EMAIL,
|
|
commitMessage,
|
|
);
|
|
}
|
|
|
|
export async function writeLocalScanManifestShards(
|
|
input: WriteLocalScanManifestShardsInput,
|
|
): Promise<WriteLocalScanManifestShardsResult> {
|
|
if (input.dryRun) {
|
|
return {
|
|
manifestShards: [],
|
|
manifestShardsWritten: 0,
|
|
};
|
|
}
|
|
|
|
const existing = await loadExistingManifestState(input.project, input.connectionId, input.snapshot);
|
|
const { shards } = buildLiveDatabaseManifestShards({
|
|
connectionType: input.driver.toUpperCase(),
|
|
tables: snapshotTablesToManifestData(input.snapshot, input.descriptionUpdates),
|
|
joins: relationshipJoins(input.snapshot, input.relationshipUpdate),
|
|
existingDescriptions: existing.descriptions,
|
|
existingPreservedJoins: existing.preservedJoins,
|
|
existingUsage: existing.usage,
|
|
mapColumnType: (dimensionType) => dimensionType,
|
|
});
|
|
|
|
const manifestShards: string[] = [];
|
|
for (const [shardKey, shard] of [...shards.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
const path = `${schemaDir(input.connectionId)}/${shardKey}.yaml`;
|
|
await input.project.fileStore.writeFile(
|
|
path,
|
|
YAML.stringify(shard, { indent: 2, lineWidth: 0, version: '1.1' }),
|
|
LOCAL_AUTHOR,
|
|
LOCAL_AUTHOR_EMAIL,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write manifest shard ${shardKey} syncId=${input.syncId}`,
|
|
);
|
|
manifestShards.push(path);
|
|
}
|
|
|
|
return {
|
|
manifestShards,
|
|
manifestShardsWritten: manifestShards.length,
|
|
};
|
|
}
|
|
|
|
export async function writeLocalScanEnrichmentArtifacts(
|
|
input: WriteLocalScanEnrichmentArtifactsInput,
|
|
): Promise<WriteLocalScanEnrichmentArtifactsResult> {
|
|
if (input.dryRun) {
|
|
return {
|
|
enrichmentArtifacts: [],
|
|
manifestShards: [],
|
|
manifestShardsWritten: 0,
|
|
};
|
|
}
|
|
|
|
const enrichmentRoot = artifactDir(input.connectionId, input.syncId);
|
|
const descriptionsArtifact = `${enrichmentRoot}/descriptions.json`;
|
|
const embeddingsArtifact = `${enrichmentRoot}/embeddings.json`;
|
|
const relationshipsArtifact = `${enrichmentRoot}/relationships.json`;
|
|
const relationshipProfileArtifact = `${enrichmentRoot}/relationship-profile.json`;
|
|
const relationshipDiagnosticsArtifact = `${enrichmentRoot}/relationship-diagnostics.json`;
|
|
const enrichmentArtifacts: string[] = [];
|
|
|
|
if (
|
|
input.enrichment.summary.tableDescriptions === 'completed' ||
|
|
input.enrichment.summary.columnDescriptions === 'completed'
|
|
) {
|
|
enrichmentArtifacts.push(descriptionsArtifact);
|
|
await writeJsonArtifact(
|
|
input.project,
|
|
descriptionsArtifact,
|
|
input.enrichment.descriptionUpdates,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write enrichment descriptions syncId=${input.syncId}`,
|
|
);
|
|
}
|
|
if (input.enrichment.summary.embeddings === 'completed') {
|
|
enrichmentArtifacts.push(embeddingsArtifact);
|
|
await writeJsonArtifact(
|
|
input.project,
|
|
embeddingsArtifact,
|
|
input.enrichment.embeddingUpdates,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write enrichment embeddings syncId=${input.syncId}`,
|
|
);
|
|
}
|
|
enrichmentArtifacts.push(relationshipsArtifact, relationshipProfileArtifact, relationshipDiagnosticsArtifact);
|
|
const hasResolvedRelationships = input.enrichment.resolvedRelationships !== null;
|
|
const relationshipArtifacts = buildKtxRelationshipArtifacts({
|
|
connectionId: input.connectionId,
|
|
resolvedRelationships: hasResolvedRelationships ? (input.enrichment.resolvedRelationships ?? []) : undefined,
|
|
compositeRelationships: input.enrichment.compositeRelationships ?? undefined,
|
|
relationshipUpdate: input.enrichment.relationshipUpdate ?? {
|
|
connectionId: input.connectionId,
|
|
accepted: [],
|
|
rejected: [],
|
|
skipped: [],
|
|
},
|
|
});
|
|
const relationshipProfile =
|
|
input.enrichment.relationshipProfile ??
|
|
emptyKtxRelationshipProfileArtifact({
|
|
connectionId: input.connectionId,
|
|
driver: input.driver,
|
|
reason: 'relationship_profiling_not_run',
|
|
});
|
|
const relationshipDiagnostics = buildKtxRelationshipDiagnostics({
|
|
connectionId: input.connectionId,
|
|
artifacts: relationshipArtifacts,
|
|
profile: relationshipProfile,
|
|
warnings: input.enrichment.warnings,
|
|
thresholds: input.relationshipSettings
|
|
? {
|
|
acceptThreshold: input.relationshipSettings.acceptThreshold,
|
|
reviewThreshold: input.relationshipSettings.reviewThreshold,
|
|
}
|
|
: undefined,
|
|
policy: input.relationshipSettings
|
|
? {
|
|
validationRequiredForManifest: input.relationshipSettings.validationRequiredForManifest,
|
|
maxCandidatesPerColumn: input.relationshipSettings.maxCandidatesPerColumn,
|
|
profileSampleRows: input.relationshipSettings.profileSampleRows,
|
|
profileConcurrency: input.relationshipSettings.profileConcurrency,
|
|
validationConcurrency: input.relationshipSettings.validationConcurrency,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
await writeJsonArtifact(
|
|
input.project,
|
|
relationshipsArtifact,
|
|
relationshipArtifacts,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write enrichment relationships syncId=${input.syncId}`,
|
|
);
|
|
await writeJsonArtifact(
|
|
input.project,
|
|
relationshipProfileArtifact,
|
|
relationshipProfile,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write relationship profile syncId=${input.syncId}`,
|
|
);
|
|
await writeJsonArtifact(
|
|
input.project,
|
|
relationshipDiagnosticsArtifact,
|
|
relationshipDiagnostics,
|
|
`scan(${LIVE_DATABASE_ADAPTER}): write relationship diagnostics syncId=${input.syncId}`,
|
|
);
|
|
|
|
const manifestResult = await writeLocalScanManifestShards({
|
|
project: input.project,
|
|
connectionId: input.connectionId,
|
|
syncId: input.syncId,
|
|
driver: input.driver,
|
|
snapshot: input.enrichment.snapshot,
|
|
descriptionUpdates: input.enrichment.descriptionUpdates,
|
|
relationshipUpdate: input.enrichment.relationshipUpdate,
|
|
dryRun: false,
|
|
});
|
|
|
|
return {
|
|
enrichmentArtifacts,
|
|
manifestShards: manifestResult.manifestShards,
|
|
manifestShardsWritten: manifestResult.manifestShardsWritten,
|
|
};
|
|
}
|