@aurios/mizzle 1.0.0
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/CHANGELOG.md +20 -0
- package/package.json +62 -0
- package/src/builders/base.ts +71 -0
- package/src/builders/batch-get.ts +76 -0
- package/src/builders/batch-write.ts +98 -0
- package/src/builders/delete.ts +61 -0
- package/src/builders/insert.ts +156 -0
- package/src/builders/query-promise.ts +41 -0
- package/src/builders/relational-builder.ts +211 -0
- package/src/builders/select.ts +196 -0
- package/src/builders/transaction.ts +170 -0
- package/src/builders/update.ts +155 -0
- package/src/columns/binary-set.ts +49 -0
- package/src/columns/binary.ts +48 -0
- package/src/columns/boolean.ts +45 -0
- package/src/columns/date.ts +83 -0
- package/src/columns/index.ts +44 -0
- package/src/columns/json.ts +62 -0
- package/src/columns/list.ts +47 -0
- package/src/columns/map.ts +46 -0
- package/src/columns/number-set.ts +49 -0
- package/src/columns/number.ts +57 -0
- package/src/columns/string-set.ts +59 -0
- package/src/columns/string.ts +64 -0
- package/src/columns/uuid.ts +51 -0
- package/src/core/client.ts +18 -0
- package/src/core/column-builder.ts +272 -0
- package/src/core/column.ts +167 -0
- package/src/core/diff.ts +50 -0
- package/src/core/errors.ts +34 -0
- package/src/core/introspection.ts +73 -0
- package/src/core/operations.ts +34 -0
- package/src/core/parser.ts +109 -0
- package/src/core/relations.ts +149 -0
- package/src/core/retry.ts +70 -0
- package/src/core/snapshot.ts +192 -0
- package/src/core/strategies.ts +271 -0
- package/src/core/table.ts +260 -0
- package/src/core/validation.ts +75 -0
- package/src/db.ts +199 -0
- package/src/expressions/actions.ts +66 -0
- package/src/expressions/builder.ts +77 -0
- package/src/expressions/operators.ts +108 -0
- package/src/expressions/update-builder.ts +98 -0
- package/src/index.ts +20 -0
- package/src/indexes.ts +19 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +20 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when a DynamoDB item exceeds the 400KB limit.
|
|
3
|
+
*/
|
|
4
|
+
export class ItemSizeExceededError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ItemSizeExceededError";
|
|
8
|
+
|
|
9
|
+
// This is necessary for some environments to correctly maintain the prototype chain
|
|
10
|
+
Object.setPrototypeOf(this, ItemSizeExceededError.prototype);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detailed reason for a transaction cancellation.
|
|
16
|
+
*/
|
|
17
|
+
export interface CancellationReason {
|
|
18
|
+
index: number;
|
|
19
|
+
code: string;
|
|
20
|
+
message?: string;
|
|
21
|
+
item?: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Error thrown when a DynamoDB transaction is canceled.
|
|
26
|
+
*/
|
|
27
|
+
export class TransactionFailedError extends Error {
|
|
28
|
+
constructor(message: string, public readonly reasons: CancellationReason[]) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "TransactionFailedError";
|
|
31
|
+
|
|
32
|
+
Object.setPrototypeOf(this, TransactionFailedError.prototype);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DynamoDBClient, ListTablesCommand, DescribeTableCommand } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import type { TableDescription } from "@aws-sdk/client-dynamodb";
|
|
3
|
+
import type { MizzleSnapshot, TableSnapshot } from "./snapshot";
|
|
4
|
+
|
|
5
|
+
export async function getRemoteSnapshot(client: DynamoDBClient): Promise<MizzleSnapshot> {
|
|
6
|
+
const tables: Record<string, TableSnapshot> = {};
|
|
7
|
+
|
|
8
|
+
// List Tables
|
|
9
|
+
let lastEvaluatedTableName: string | undefined;
|
|
10
|
+
const tableNames: string[] = [];
|
|
11
|
+
do {
|
|
12
|
+
const response = await client.send(new ListTablesCommand({ ExclusiveStartTableName: lastEvaluatedTableName }));
|
|
13
|
+
if (response.TableNames) tableNames.push(...response.TableNames);
|
|
14
|
+
lastEvaluatedTableName = response.LastEvaluatedTableName;
|
|
15
|
+
} while (lastEvaluatedTableName);
|
|
16
|
+
|
|
17
|
+
// Describe each table
|
|
18
|
+
for (const tableName of tableNames) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await client.send(new DescribeTableCommand({ TableName: tableName }));
|
|
21
|
+
if (response.Table) {
|
|
22
|
+
const snap = convertDescriptionToSnapshot(response.Table);
|
|
23
|
+
tables[tableName] = snap;
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.warn(`Failed to describe table ${tableName}:`, e);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
version: "remote",
|
|
32
|
+
tables
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function convertDescriptionToSnapshot(desc: TableDescription): TableSnapshot {
|
|
37
|
+
// Map DescribeTableOutput to TableSnapshot
|
|
38
|
+
const result: TableSnapshot = {
|
|
39
|
+
TableName: desc.TableName!,
|
|
40
|
+
AttributeDefinitions: desc.AttributeDefinitions?.map(ad => ({
|
|
41
|
+
AttributeName: ad.AttributeName!,
|
|
42
|
+
AttributeType: ad.AttributeType!
|
|
43
|
+
})).sort((a, b) => a.AttributeName.localeCompare(b.AttributeName)) || [],
|
|
44
|
+
KeySchema: desc.KeySchema?.map(ks => ({
|
|
45
|
+
AttributeName: ks.AttributeName!,
|
|
46
|
+
KeyType: ks.KeyType as "HASH" | "RANGE"
|
|
47
|
+
})) || []
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (desc.GlobalSecondaryIndexes && desc.GlobalSecondaryIndexes.length > 0) {
|
|
51
|
+
result.GlobalSecondaryIndexes = desc.GlobalSecondaryIndexes.map(gsi => ({
|
|
52
|
+
IndexName: gsi.IndexName!,
|
|
53
|
+
KeySchema: gsi.KeySchema?.map(ks => ({
|
|
54
|
+
AttributeName: ks.AttributeName!,
|
|
55
|
+
KeyType: ks.KeyType as "HASH" | "RANGE"
|
|
56
|
+
})),
|
|
57
|
+
Projection: gsi.Projection ? { ProjectionType: gsi.Projection.ProjectionType } : undefined
|
|
58
|
+
})).sort((a, b) => a.IndexName.localeCompare(b.IndexName));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (desc.LocalSecondaryIndexes && desc.LocalSecondaryIndexes.length > 0) {
|
|
62
|
+
result.LocalSecondaryIndexes = desc.LocalSecondaryIndexes.map(lsi => ({
|
|
63
|
+
IndexName: lsi.IndexName!,
|
|
64
|
+
KeySchema: lsi.KeySchema?.map(ks => ({
|
|
65
|
+
AttributeName: ks.AttributeName!,
|
|
66
|
+
KeyType: ks.KeyType as "HASH" | "RANGE"
|
|
67
|
+
})),
|
|
68
|
+
Projection: lsi.Projection ? { ProjectionType: lsi.Projection.ProjectionType } : undefined
|
|
69
|
+
})).sort((a, b) => a.IndexName.localeCompare(b.IndexName));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Column, AnyColumn } from "./column";
|
|
2
|
+
import type { PhysicalTable } from "./table";
|
|
3
|
+
|
|
4
|
+
export type RequiredKeyOnly<
|
|
5
|
+
TKey extends string,
|
|
6
|
+
T extends Column,
|
|
7
|
+
TInferMode extends "select" | "insert" = "select",
|
|
8
|
+
> = TInferMode extends "select"
|
|
9
|
+
? never
|
|
10
|
+
: T extends AnyColumn<{
|
|
11
|
+
hasDefault: false;
|
|
12
|
+
notNull: true;
|
|
13
|
+
}>
|
|
14
|
+
? TKey
|
|
15
|
+
: never;
|
|
16
|
+
|
|
17
|
+
export type OpitionalKeyOnly<
|
|
18
|
+
TKey extends string,
|
|
19
|
+
T extends Column,
|
|
20
|
+
TInferMode extends "select" | "insert" = "select",
|
|
21
|
+
> = TKey extends RequiredKeyOnly<TKey, T, TInferMode> ? never : TKey;
|
|
22
|
+
|
|
23
|
+
export type SelectedFieldsFlat<TColumn extends Column> = Record<
|
|
24
|
+
string,
|
|
25
|
+
TColumn
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
export type SelectedFields<
|
|
29
|
+
TColumn extends Column,
|
|
30
|
+
TTable extends PhysicalTable,
|
|
31
|
+
> = Record<
|
|
32
|
+
string,
|
|
33
|
+
SelectedFieldsFlat<TColumn>[string] | TTable | SelectedFieldsFlat<TColumn>
|
|
34
|
+
>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ENTITY_SYMBOLS, TABLE_SYMBOLS } from "@mizzle/shared";
|
|
2
|
+
import type { InternalRelationalSchema } from "./relations";
|
|
3
|
+
import { type KeyStrategy } from "./strategies";
|
|
4
|
+
import { Entity } from "./table";
|
|
5
|
+
import { Column } from "./column";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parser for DynamoDB item collections (Single-Table Design).
|
|
9
|
+
*/
|
|
10
|
+
export class ItemCollectionParser {
|
|
11
|
+
constructor(private schema: InternalRelationalSchema) {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a flat list of items into structured, nested objects.
|
|
15
|
+
*/
|
|
16
|
+
parse(
|
|
17
|
+
items: Record<string, unknown>[],
|
|
18
|
+
rootEntityName: string,
|
|
19
|
+
relations: Record<string, boolean | object> = {},
|
|
20
|
+
): Record<string, unknown>[] {
|
|
21
|
+
const rootEntityMeta = this.schema.entities[rootEntityName];
|
|
22
|
+
if (!rootEntityMeta) {
|
|
23
|
+
throw new Error(`Root entity ${rootEntityName} not found in schema`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const primaryItems: Record<string, unknown>[] = [];
|
|
27
|
+
const otherItems: Record<string, unknown>[] = [];
|
|
28
|
+
|
|
29
|
+
// 1. Identify primary items vs related items
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
if (this.isEntity(item, rootEntityMeta.entity)) {
|
|
32
|
+
primaryItems.push({ ...item });
|
|
33
|
+
} else {
|
|
34
|
+
otherItems.push(item);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. For each primary item, find its relations
|
|
39
|
+
for (const primaryItem of primaryItems) {
|
|
40
|
+
for (const [relName, relOption] of Object.entries(relations)) {
|
|
41
|
+
if (!relOption) continue;
|
|
42
|
+
|
|
43
|
+
const relConfig = rootEntityMeta.relations[relName];
|
|
44
|
+
if (!relConfig) continue;
|
|
45
|
+
|
|
46
|
+
const targetEntity = relConfig.config.to;
|
|
47
|
+
|
|
48
|
+
// Find items that match the target entity type
|
|
49
|
+
// In Single-Table Design Query, these items usually share the same PK
|
|
50
|
+
const relatedItems = otherItems.filter((item) =>
|
|
51
|
+
this.isEntity(item, targetEntity),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (relatedItems.length > 0) {
|
|
55
|
+
if (relConfig.type === "many") {
|
|
56
|
+
primaryItem[relName] = relatedItems;
|
|
57
|
+
} else if (relConfig.type === "one") {
|
|
58
|
+
primaryItem[relName] = relatedItems[0] || null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return primaryItems;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if an item matches an entity definition based on its key strategies.
|
|
69
|
+
*/
|
|
70
|
+
private isEntity(item: Record<string, unknown>, entity: Entity): boolean {
|
|
71
|
+
const strategies = entity[ENTITY_SYMBOLS.ENTITY_STRATEGY] as Record<
|
|
72
|
+
string,
|
|
73
|
+
KeyStrategy
|
|
74
|
+
>;
|
|
75
|
+
const physicalTable = entity[ENTITY_SYMBOLS.PHYSICAL_TABLE] as any;
|
|
76
|
+
|
|
77
|
+
const pkName = (physicalTable[TABLE_SYMBOLS.PARTITION_KEY] as Column).name;
|
|
78
|
+
const skName = (physicalTable[TABLE_SYMBOLS.SORT_KEY] as Column | undefined)
|
|
79
|
+
?.name;
|
|
80
|
+
|
|
81
|
+
const pkMatch = this.matchStrategy(item[pkName], strategies.pk);
|
|
82
|
+
const skMatch = skName
|
|
83
|
+
? this.matchStrategy(item[skName], strategies.sk)
|
|
84
|
+
: true;
|
|
85
|
+
|
|
86
|
+
return pkMatch && skMatch;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a value matches a key strategy.
|
|
91
|
+
*/
|
|
92
|
+
private matchStrategy(value: unknown, strategy?: KeyStrategy): boolean {
|
|
93
|
+
if (!strategy) return true;
|
|
94
|
+
if (value === undefined || value === null) return false;
|
|
95
|
+
|
|
96
|
+
const strValue = String(value);
|
|
97
|
+
|
|
98
|
+
if (strategy.type === "static") {
|
|
99
|
+
return strValue === strategy.segments[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (strategy.type === "prefix" || strategy.type === "composite") {
|
|
103
|
+
const prefix = strategy.segments[0];
|
|
104
|
+
return typeof prefix === "string" && strValue.startsWith(prefix);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { RELATION_SYMBOLS } from "@mizzle/shared";
|
|
2
|
+
import { Entity } from "./table";
|
|
3
|
+
import { Column } from "./column";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Type of relationship.
|
|
7
|
+
*/
|
|
8
|
+
export type RelationType = "one" | "many";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration for a relationship.
|
|
12
|
+
*/
|
|
13
|
+
export interface RelationConfig {
|
|
14
|
+
/**
|
|
15
|
+
* The target entity of the relationship.
|
|
16
|
+
*/
|
|
17
|
+
to: Entity;
|
|
18
|
+
/**
|
|
19
|
+
* Local columns that link to the target entity.
|
|
20
|
+
*/
|
|
21
|
+
fields?: Column[];
|
|
22
|
+
/**
|
|
23
|
+
* Target columns that the local columns link to.
|
|
24
|
+
*/
|
|
25
|
+
references?: Column[];
|
|
26
|
+
/**
|
|
27
|
+
* Optional name for the relation.
|
|
28
|
+
*/
|
|
29
|
+
relationName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Represents a relationship between entities.
|
|
34
|
+
*/
|
|
35
|
+
export class Relation<TType extends RelationType = RelationType> {
|
|
36
|
+
constructor(
|
|
37
|
+
public type: TType,
|
|
38
|
+
public config: RelationConfig
|
|
39
|
+
) {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Definition of all relations for a single entity.
|
|
44
|
+
*/
|
|
45
|
+
export interface RelationsDefinition<TEntity extends Entity = Entity> {
|
|
46
|
+
/**
|
|
47
|
+
* The source entity.
|
|
48
|
+
*/
|
|
49
|
+
entity: TEntity;
|
|
50
|
+
/**
|
|
51
|
+
* Map of relation names to their configurations.
|
|
52
|
+
*/
|
|
53
|
+
config: Record<string, Relation>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callback function to define relations.
|
|
58
|
+
*/
|
|
59
|
+
export type RelationsCallback = (helpers: {
|
|
60
|
+
/**
|
|
61
|
+
* Define a one-to-one relationship.
|
|
62
|
+
*/
|
|
63
|
+
one: (to: Entity, config?: Omit<RelationConfig, "to">) => Relation<"one">;
|
|
64
|
+
/**
|
|
65
|
+
* Define a one-to-many relationship.
|
|
66
|
+
*/
|
|
67
|
+
many: (to: Entity, config?: Omit<RelationConfig, "to">) => Relation<"many">;
|
|
68
|
+
}) => Record<string, Relation>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Define relations for an entity.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* export const usersRelations = defineRelations(users, ({ many }) => ({
|
|
76
|
+
* posts: many(posts),
|
|
77
|
+
* }));
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function defineRelations<TEntity extends Entity>(
|
|
81
|
+
entity: TEntity,
|
|
82
|
+
relations: RelationsCallback
|
|
83
|
+
): RelationsDefinition<TEntity> {
|
|
84
|
+
const config = relations({
|
|
85
|
+
one: (to, config) => new Relation("one", { to, ...config }),
|
|
86
|
+
many: (to, config) => new Relation("many", { to, ...config }),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
entity,
|
|
91
|
+
config,
|
|
92
|
+
[RELATION_SYMBOLS.RELATION_CONFIG]: true
|
|
93
|
+
} as unknown as RelationsDefinition<TEntity>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Metadata for an entity and its relationships.
|
|
98
|
+
*/
|
|
99
|
+
export interface EntityMetadata {
|
|
100
|
+
entity: Entity;
|
|
101
|
+
relations: Record<string, Relation>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Internal relational schema mapping entity names to their metadata.
|
|
106
|
+
*/
|
|
107
|
+
export interface InternalRelationalSchema {
|
|
108
|
+
entities: Record<string, EntityMetadata>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract metadata from a flat schema definition.
|
|
113
|
+
*/
|
|
114
|
+
export function extractMetadata(schema: Record<string, unknown>): InternalRelationalSchema {
|
|
115
|
+
const metadata: InternalRelationalSchema = {
|
|
116
|
+
entities: {},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// First pass: identify entities
|
|
120
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
121
|
+
if (value instanceof Entity) {
|
|
122
|
+
metadata.entities[key] = {
|
|
123
|
+
entity: value,
|
|
124
|
+
relations: {},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Second pass: identify relations
|
|
130
|
+
for (const [, value] of Object.entries(schema)) {
|
|
131
|
+
if (value && (value as any)[RELATION_SYMBOLS.RELATION_CONFIG]) {
|
|
132
|
+
const definition = value as RelationsDefinition;
|
|
133
|
+
// Find the key for this entity in the metadata
|
|
134
|
+
const entityEntry = Object.entries(metadata.entities).find(
|
|
135
|
+
([_, meta]) => meta.entity === definition.entity
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (entityEntry) {
|
|
139
|
+
const [, meta] = entityEntry;
|
|
140
|
+
meta.relations = {
|
|
141
|
+
...meta.relations,
|
|
142
|
+
...definition.config,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return metadata;
|
|
149
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface RetryConfig {
|
|
2
|
+
/**
|
|
3
|
+
* Maximum number of attempts (including the initial one).
|
|
4
|
+
* @default 3
|
|
5
|
+
*/
|
|
6
|
+
maxAttempts: number;
|
|
7
|
+
/**
|
|
8
|
+
* Base delay in milliseconds for exponential backoff.
|
|
9
|
+
* @default 100
|
|
10
|
+
*/
|
|
11
|
+
baseDelay: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RETRYABLE_ERRORS = new Set([
|
|
15
|
+
"ProvisionedThroughputExceededException",
|
|
16
|
+
"RequestLimitExceeded",
|
|
17
|
+
"InternalServerError",
|
|
18
|
+
"ServiceUnavailable",
|
|
19
|
+
"ThrottlingException",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const RETRYABLE_STATUS_CODES = new Set([500, 503]);
|
|
23
|
+
|
|
24
|
+
export class RetryHandler {
|
|
25
|
+
constructor(private config: RetryConfig) {}
|
|
26
|
+
|
|
27
|
+
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
|
28
|
+
let lastError: unknown;
|
|
29
|
+
|
|
30
|
+
for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
return await operation();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
lastError = error;
|
|
35
|
+
|
|
36
|
+
if (attempt >= this.config.maxAttempts || !this.isRetryable(error)) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await this.delay(attempt - 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw lastError;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private isRetryable(error: any): boolean {
|
|
48
|
+
if (!error) return false;
|
|
49
|
+
|
|
50
|
+
if (RETRYABLE_ERRORS.has(error.name)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error.$metadata?.httpStatusCode && RETRYABLE_STATUS_CODES.has(error.$metadata.httpStatusCode)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// AWS SDK v3 specific error structure sometimes wraps things
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async delay(retryCount: number): Promise<void> {
|
|
63
|
+
const base = this.config.baseDelay * Math.pow(2, retryCount);
|
|
64
|
+
const jitter = Math.random() * base; // Full jitter or equal jitter? Standard is random between 0 and base.
|
|
65
|
+
// Cap at some reasonable max if needed, but for now standard exponential + jitter
|
|
66
|
+
const delayTime = base + jitter;
|
|
67
|
+
|
|
68
|
+
return new Promise(resolve => setTimeout(resolve, delayTime));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { writeFile, readFile, mkdir, readdir } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { PhysicalTable, Entity } from "./table";
|
|
5
|
+
import { TABLE_SYMBOLS, ENTITY_SYMBOLS } from "@mizzle/shared";
|
|
6
|
+
import { Column } from "./column";
|
|
7
|
+
import type {
|
|
8
|
+
AttributeDefinition,
|
|
9
|
+
KeySchemaElement,
|
|
10
|
+
GlobalSecondaryIndex,
|
|
11
|
+
LocalSecondaryIndex,
|
|
12
|
+
} from "@aws-sdk/client-dynamodb";
|
|
13
|
+
|
|
14
|
+
export interface TableSnapshot {
|
|
15
|
+
TableName: string;
|
|
16
|
+
AttributeDefinitions: AttributeDefinition[];
|
|
17
|
+
KeySchema: KeySchemaElement[];
|
|
18
|
+
GlobalSecondaryIndexes?: GlobalSecondaryIndex[];
|
|
19
|
+
LocalSecondaryIndexes?: LocalSecondaryIndex[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MizzleSnapshot {
|
|
23
|
+
version: string;
|
|
24
|
+
tables: Record<string, TableSnapshot>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SchemaCurrent {
|
|
28
|
+
tables: PhysicalTable[];
|
|
29
|
+
entities: Entity[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SNAPSHOT_FILENAME = "snapshot.json";
|
|
33
|
+
|
|
34
|
+
export async function saveSnapshot(dir: string, snapshot: MizzleSnapshot): Promise<void> {
|
|
35
|
+
if (!existsSync(dir)) {
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
const filePath = join(dir, SNAPSHOT_FILENAME);
|
|
39
|
+
await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadSnapshot(dir: string): Promise<MizzleSnapshot | null> {
|
|
43
|
+
const filePath = join(dir, SNAPSHOT_FILENAME);
|
|
44
|
+
if (!existsSync(filePath)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const content = await readFile(filePath, "utf-8");
|
|
48
|
+
return JSON.parse(content) as MizzleSnapshot;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function generateSnapshot(schema: SchemaCurrent): MizzleSnapshot {
|
|
52
|
+
const tables: Record<string, TableSnapshot> = {};
|
|
53
|
+
|
|
54
|
+
for (const table of schema.tables) {
|
|
55
|
+
const associatedEntities = schema.entities.filter(e => e[ENTITY_SYMBOLS.PHYSICAL_TABLE] === table);
|
|
56
|
+
const tableSnapshot = physicalTableToSnapshot(table, associatedEntities);
|
|
57
|
+
tables[tableSnapshot.TableName] = tableSnapshot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
version: "1",
|
|
62
|
+
tables
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getNextMigrationVersion(migrationsDir: string): Promise<string> {
|
|
67
|
+
if (!existsSync(migrationsDir)) return "0000";
|
|
68
|
+
|
|
69
|
+
const files = await readdir(migrationsDir);
|
|
70
|
+
let maxVersion = -1;
|
|
71
|
+
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (!file.endsWith(".ts")) continue;
|
|
74
|
+
const match = file.match(/^(\d{4})_/);
|
|
75
|
+
if (match) {
|
|
76
|
+
const version = parseInt(match[1]!, 10);
|
|
77
|
+
if (version > maxVersion) maxVersion = version;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (maxVersion === -1) return "0000";
|
|
82
|
+
return (maxVersion + 1).toString().padStart(4, "0");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function physicalTableToSnapshot(table: PhysicalTable, entities: Entity[]): TableSnapshot {
|
|
86
|
+
const tableName = table[TABLE_SYMBOLS.TABLE_NAME];
|
|
87
|
+
const attributeDefinitionsMap = new Map<string, string>(); // Name -> Type
|
|
88
|
+
|
|
89
|
+
// PK
|
|
90
|
+
const pk = table[TABLE_SYMBOLS.PARTITION_KEY] as Column;
|
|
91
|
+
attributeDefinitionsMap.set(pk.name, pk.getDynamoType());
|
|
92
|
+
|
|
93
|
+
// SK
|
|
94
|
+
const sk = table[TABLE_SYMBOLS.SORT_KEY] as Column | undefined;
|
|
95
|
+
if (sk) {
|
|
96
|
+
attributeDefinitionsMap.set(sk.name, sk.getDynamoType());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const keySchema: KeySchemaElement[] = [
|
|
100
|
+
{ AttributeName: pk.name, KeyType: "HASH" }
|
|
101
|
+
];
|
|
102
|
+
if (sk) {
|
|
103
|
+
keySchema.push({ AttributeName: sk.name, KeyType: "RANGE" });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const gsis: GlobalSecondaryIndex[] = [];
|
|
107
|
+
const lsis: LocalSecondaryIndex[] = [];
|
|
108
|
+
|
|
109
|
+
const indexes = table[TABLE_SYMBOLS.INDEXES] || {};
|
|
110
|
+
for (const [indexName, indexBuilder] of Object.entries(indexes)) {
|
|
111
|
+
const type = (indexBuilder as { type: string }).type;
|
|
112
|
+
const config = (indexBuilder as { config: { pk?: string; sk?: string } }).config;
|
|
113
|
+
|
|
114
|
+
if (type === 'gsi') {
|
|
115
|
+
if (config.pk) {
|
|
116
|
+
const pkType = resolveColumnType(config.pk, table, entities);
|
|
117
|
+
attributeDefinitionsMap.set(config.pk, pkType);
|
|
118
|
+
}
|
|
119
|
+
if (config.sk) {
|
|
120
|
+
const skType = resolveColumnType(config.sk, table, entities);
|
|
121
|
+
attributeDefinitionsMap.set(config.sk, skType);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const gsiDef: GlobalSecondaryIndex = {
|
|
125
|
+
IndexName: indexName,
|
|
126
|
+
KeySchema: [
|
|
127
|
+
{ AttributeName: config.pk!, KeyType: "HASH" }
|
|
128
|
+
],
|
|
129
|
+
Projection: { ProjectionType: "ALL" }
|
|
130
|
+
};
|
|
131
|
+
if (config.sk) {
|
|
132
|
+
gsiDef.KeySchema!.push({ AttributeName: config.sk, KeyType: "RANGE" });
|
|
133
|
+
}
|
|
134
|
+
gsis.push(gsiDef);
|
|
135
|
+
|
|
136
|
+
} else if (type === 'lsi') {
|
|
137
|
+
if (config.sk) {
|
|
138
|
+
const skType = resolveColumnType(config.sk, table, entities);
|
|
139
|
+
attributeDefinitionsMap.set(config.sk, skType);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lsiDef: LocalSecondaryIndex = {
|
|
143
|
+
IndexName: indexName,
|
|
144
|
+
KeySchema: [
|
|
145
|
+
{ AttributeName: pk.name, KeyType: "HASH" },
|
|
146
|
+
{ AttributeName: config.sk!, KeyType: "RANGE" }
|
|
147
|
+
],
|
|
148
|
+
Projection: { ProjectionType: "ALL" }
|
|
149
|
+
};
|
|
150
|
+
lsis.push(lsiDef);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const attributeDefinitions = Array.from(attributeDefinitionsMap.entries()).map(([name, type]) => ({
|
|
155
|
+
AttributeName: name,
|
|
156
|
+
AttributeType: type as any
|
|
157
|
+
})).sort((a, b) => a.AttributeName.localeCompare(b.AttributeName));
|
|
158
|
+
|
|
159
|
+
gsis.sort((a, b) => (a.IndexName || "").localeCompare(b.IndexName || ""));
|
|
160
|
+
lsis.sort((a, b) => (a.IndexName || "").localeCompare(b.IndexName || ""));
|
|
161
|
+
|
|
162
|
+
const result: TableSnapshot = {
|
|
163
|
+
TableName: tableName as string,
|
|
164
|
+
AttributeDefinitions: attributeDefinitions,
|
|
165
|
+
KeySchema: keySchema,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (gsis.length > 0) result.GlobalSecondaryIndexes = gsis;
|
|
169
|
+
if (lsis.length > 0) result.LocalSecondaryIndexes = lsis;
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveColumnType(columnName: string, table: PhysicalTable, entities: Entity[]): string {
|
|
175
|
+
const pk = table[TABLE_SYMBOLS.PARTITION_KEY] as Column;
|
|
176
|
+
if (pk.name === columnName) return pk.getDynamoType();
|
|
177
|
+
|
|
178
|
+
const sk = table[TABLE_SYMBOLS.SORT_KEY] as Column | undefined;
|
|
179
|
+
if (sk && sk.name === columnName) return sk.getDynamoType();
|
|
180
|
+
|
|
181
|
+
for (const entity of entities) {
|
|
182
|
+
const columns = entity[ENTITY_SYMBOLS.COLUMNS] as Record<string, Column> | undefined;
|
|
183
|
+
if (columns) {
|
|
184
|
+
const col = columns[columnName];
|
|
185
|
+
if (col) {
|
|
186
|
+
return col.getDynamoType();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw new Error(`Could not resolve type for column '${columnName}' in table '${table[TABLE_SYMBOLS.TABLE_NAME]}'. Ensure it is defined in an Entity.`);
|
|
192
|
+
}
|