nodedb 0.3.0-beta.1

Local-first, real-time, edge-to-cloud hybrid database for multi-modal workloads
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
// SPDX-License-Identifier: BUSL-1.1

//! Integration tests for typeguard DEFAULT/VALUE expressions and VALIDATE TYPEGUARD.
//!
//! Verifies that:
//! - DEFAULT injects a value when the field is absent
//! - DEFAULT does not overwrite user-provided values
//! - VALUE always overwrites, even when user provides a value
//! - REQUIRED + DEFAULT = field is always present
//! - Cross-field VALUE expressions resolve other document fields

mod common;

use common::pgwire_harness::TestServer;

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typeguard_default_injects_when_absent() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION tg_defaults").await.unwrap();

    server
        .exec(
            "CREATE TYPEGUARD ON tg_defaults (\
                 status STRING DEFAULT 'draft'\
             )",
        )
        .await
        .unwrap();

    // Insert without status — DEFAULT should fill it.
    server
        .exec("INSERT INTO tg_defaults { id: 'd1', name: 'Alice' }")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM tg_defaults WHERE id = 'd1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    assert!(
        rows[0].contains("draft"),
        "DEFAULT should inject 'draft': {rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typeguard_default_does_not_overwrite() {
    let server = TestServer::start().await;

    server
        .exec("CREATE COLLECTION tg_no_overwrite")
        .await
        .unwrap();

    server
        .exec(
            "CREATE TYPEGUARD ON tg_no_overwrite (\
                 status STRING DEFAULT 'draft'\
             )",
        )
        .await
        .unwrap();

    // Insert with explicit status — DEFAULT should NOT overwrite.
    server
        .exec("INSERT INTO tg_no_overwrite { id: 'd1', status: 'active' }")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM tg_no_overwrite WHERE id = 'd1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    assert!(
        rows[0].contains("active"),
        "DEFAULT should not overwrite user value: {rows:?}"
    );
    assert!(
        !rows[0].contains("draft"),
        "should NOT contain default: {rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typeguard_value_always_overwrites() {
    let server = TestServer::start().await;

    server
        .exec("CREATE COLLECTION tg_value_overwrite")
        .await
        .unwrap();

    server
        .exec(
            "CREATE TYPEGUARD ON tg_value_overwrite (\
                 computed STRING VALUE 'server_computed'\
             )",
        )
        .await
        .unwrap();

    // Insert with user-provided value — VALUE should overwrite.
    server
        .exec("INSERT INTO tg_value_overwrite { id: 'v1', computed: 'user_input' }")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM tg_value_overwrite WHERE id = 'v1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    assert!(
        rows[0].contains("server_computed"),
        "VALUE should overwrite user input: {rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typeguard_required_plus_default() {
    let server = TestServer::start().await;

    server
        .exec("CREATE COLLECTION tg_req_default")
        .await
        .unwrap();

    server
        .exec(
            "CREATE TYPEGUARD ON tg_req_default (\
                 version INT REQUIRED DEFAULT 1\
             )",
        )
        .await
        .unwrap();

    // Insert without version — DEFAULT fills before REQUIRED check.
    server
        .exec("INSERT INTO tg_req_default { id: 'r1', name: 'test' }")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM tg_req_default WHERE id = 'r1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    assert!(
        rows[0].contains('1'),
        "REQUIRED + DEFAULT should inject version=1: {rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typeguard_default_integer() {
    let server = TestServer::start().await;

    server
        .exec("CREATE COLLECTION tg_int_default")
        .await
        .unwrap();

    server
        .exec(
            "CREATE TYPEGUARD ON tg_int_default (\
                 priority INT DEFAULT 0 CHECK (priority >= 0)\
             )",
        )
        .await
        .unwrap();

    // Insert without priority — DEFAULT 0 should be injected and pass CHECK.
    server
        .exec("INSERT INTO tg_int_default { id: 'p1', name: 'test' }")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM tg_int_default WHERE id = 'p1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
}

// ── VALIDATE TYPEGUARD ──

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn validate_typeguard_no_violations() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION val_clean").await.unwrap();

    // Insert valid data first.
    server
        .exec("INSERT INTO val_clean { id: 'v1', name: 'Alice', age: 25 }")
        .await
        .unwrap();

    // Add type guard after data.
    server
        .exec(
            "CREATE TYPEGUARD ON val_clean (\
                 name STRING,\
                 age INT\
             )",
        )
        .await
        .unwrap();

    // Validate — all docs should pass.
    let rows = server
        .query_text("VALIDATE TYPEGUARD ON val_clean")
        .await
        .unwrap();
    assert_eq!(rows.len(), 0, "no violations expected: {rows:?}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn validate_typeguard_finds_violations() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION val_dirty").await.unwrap();

    // Insert data that will violate a future type guard.
    server
        .exec("INSERT INTO val_dirty { id: 'd1', name: 'Alice', score: 42 }")
        .await
        .unwrap();
    server
        .exec("INSERT INTO val_dirty { id: 'd2', name: 123, score: 99 }")
        .await
        .unwrap();

    // Add type guard — name must be STRING.
    server
        .exec(
            "CREATE TYPEGUARD ON val_dirty (\
                 name STRING\
             )",
        )
        .await
        .unwrap();

    // Validate — d2 has name=123 (INT, not STRING).
    let rows = server
        .query_text("VALIDATE TYPEGUARD ON val_dirty")
        .await
        .unwrap();
    assert!(
        !rows.is_empty(),
        "should find at least one violation: {rows:?}"
    );
    // First column is document_id — should be d2.
    assert!(
        rows.iter().any(|r| r.contains("d2")),
        "violation should reference d2: {rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn validate_typeguard_no_guards() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION val_noguard").await.unwrap();

    server
        .exec("INSERT INTO val_noguard { id: 'n1', x: 1 }")
        .await
        .unwrap();

    // No typeguard — should return empty result.
    let rows = server
        .query_text("VALIDATE TYPEGUARD ON val_noguard")
        .await
        .unwrap();
    assert_eq!(rows.len(), 0);
}

// ── CONVERT TO strict ──

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn convert_to_strict_from_typeguards() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION conv_tg").await.unwrap();

    // Add typeguards with types and CHECK.
    server
        .exec(
            "CREATE TYPEGUARD ON conv_tg (\
                 name STRING REQUIRED,\
                 age INT CHECK (age >= 0)\
             )",
        )
        .await
        .unwrap();

    // Insert valid data.
    server
        .exec("INSERT INTO conv_tg { id: 'c1', name: 'Alice', age: 25 }")
        .await
        .unwrap();

    // Convert to strict WITHOUT explicit column defs — should infer from typeguards.
    server
        .exec("CONVERT COLLECTION conv_tg TO document_strict")
        .await
        .unwrap();

    // Typeguards should be gone.
    let tg_rows = server
        .query_text("SHOW TYPEGUARD ON conv_tg")
        .await
        .unwrap();
    assert_eq!(
        tg_rows.len(),
        0,
        "typeguards should be cleared: {tg_rows:?}"
    );

    // CHECK constraints should be carried over.
    let constraint_rows = server
        .query_text("SHOW CONSTRAINTS ON conv_tg")
        .await
        .unwrap();
    assert!(
        constraint_rows.iter().any(|r| r.contains("_guard_age")),
        "CHECK from typeguard should carry over: {constraint_rows:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn convert_to_strict_no_typeguards_no_cols_errors() {
    let server = TestServer::start().await;

    server.exec("CREATE COLLECTION conv_empty").await.unwrap();

    // No typeguards, no column defs — should fail.
    let err = server
        .exec("CONVERT COLLECTION conv_empty TO document_strict")
        .await;
    assert!(
        err.is_err(),
        "should fail without typeguards or column defs: {err:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn convert_to_strict_with_explicit_cols() {
    let server = TestServer::start().await;

    server
        .exec("CREATE COLLECTION conv_explicit")
        .await
        .unwrap();

    server
        .exec("INSERT INTO conv_explicit { id: 'e1', val: 42 }")
        .await
        .unwrap();

    // Convert with explicit column defs (should still work as before).
    let result = server
        .exec("CONVERT COLLECTION conv_explicit TO document_strict (id TEXT, val INT)")
        .await;
    assert!(result.is_ok(), "explicit convert should work");
}

// ── DEFAULT gen_uuid_v7() / now() on strict schema ──

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn strict_default_gen_uuid_v7() {
    let server = TestServer::start().await;

    server
        .exec(
            "CREATE COLLECTION strict_uuid (\
                 id TEXT PRIMARY KEY DEFAULT gen_uuid_v7(),\
                 name TEXT\
             ) WITH (engine='document_strict')",
        )
        .await
        .unwrap();

    // Insert without id — DEFAULT gen_uuid_v7() should fill it.
    let result = server
        .exec("INSERT INTO strict_uuid (name) VALUES ('Alice')")
        .await;
    result.unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM strict_uuid")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1, "should have 1 row: {rows:?}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn strict_default_now() {
    let server = TestServer::start().await;

    server
        .exec(
            "CREATE COLLECTION strict_ts (\
                 id TEXT PRIMARY KEY,\
                 created_at TEXT DEFAULT now()\
             ) WITH (engine='document_strict')",
        )
        .await
        .unwrap();

    // Insert without created_at — DEFAULT now() should fill it.
    server
        .exec("INSERT INTO strict_ts (id) VALUES ('t1')")
        .await
        .unwrap();

    let rows = server
        .query_text_joined("SELECT * FROM strict_ts WHERE id = 't1'")
        .await
        .unwrap();
    assert_eq!(rows.len(), 1, "should have 1 row: {rows:?}");
}