meadow-endpoints 4.0.17 → 4.0.19
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/dist/meadow-endpoints.js +2195 -228
- package/dist/meadow-endpoints.js.map +1 -1
- package/dist/meadow-endpoints.min.js +21 -16
- package/dist/meadow-endpoints.min.js.map +1 -1
- package/package.json +5 -5
- package/source/Meadow-Endpoints.js +10 -0
- package/source/endpoints/update/Meadow-Endpoint-Update.js +17 -1
- package/source/endpoints/upsert/Meadow-Endpoint-BulkUpsert.js +25 -1
- package/source/endpoints/upsert/Meadow-Endpoint-BulkUpsertDetailed.js +96 -0
- package/source/endpoints/upsert/Meadow-Operation-Upsert.js +15 -0
- package/test/MeadowEndpoints_basic_tests.js +552 -0
|
@@ -2736,5 +2736,557 @@ suite
|
|
|
2736
2736
|
);
|
|
2737
2737
|
}
|
|
2738
2738
|
);
|
|
2739
|
+
|
|
2740
|
+
// ======================================================================
|
|
2741
|
+
// Update by primary key via URL (PUT/PATCH /:IDRecord)
|
|
2742
|
+
// ----------------------------------------------------------------------
|
|
2743
|
+
// REST-idiomatic in-place update. URL ID is authoritative — clients no
|
|
2744
|
+
// longer need to GET → DELETE → INSERT to "edit" a row, which was the
|
|
2745
|
+
// pattern that churned auto-ids and broke ID-holding consumers.
|
|
2746
|
+
// ======================================================================
|
|
2747
|
+
suite
|
|
2748
|
+
(
|
|
2749
|
+
'Update by primary key via URL',
|
|
2750
|
+
() =>
|
|
2751
|
+
{
|
|
2752
|
+
let _ByIDRecordID = 0;
|
|
2753
|
+
|
|
2754
|
+
suiteSetup
|
|
2755
|
+
(
|
|
2756
|
+
function (fDone)
|
|
2757
|
+
{
|
|
2758
|
+
_SuperTest
|
|
2759
|
+
.post('1.0/Book')
|
|
2760
|
+
.send({ Title: 'PUT-by-id seed', Genre: 'Test' })
|
|
2761
|
+
.end(
|
|
2762
|
+
(pError, pResponse) =>
|
|
2763
|
+
{
|
|
2764
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2765
|
+
_ByIDRecordID = tmpResult.IDBook;
|
|
2766
|
+
Expect(_ByIDRecordID).to.be.above(0);
|
|
2767
|
+
fDone();
|
|
2768
|
+
}
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
);
|
|
2772
|
+
|
|
2773
|
+
test
|
|
2774
|
+
(
|
|
2775
|
+
'PUT /:IDRecord updates in place and preserves the primary key',
|
|
2776
|
+
function (fDone)
|
|
2777
|
+
{
|
|
2778
|
+
_SuperTest
|
|
2779
|
+
.put(`1.0/Book/${_ByIDRecordID}`)
|
|
2780
|
+
.send({ Title: 'PUT-by-id updated', Genre: 'Updated' })
|
|
2781
|
+
.end(
|
|
2782
|
+
(pError, pResponse) =>
|
|
2783
|
+
{
|
|
2784
|
+
Expect(pResponse.status).to.equal(200);
|
|
2785
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2786
|
+
Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
|
|
2787
|
+
Expect(tmpResult.Title).to.equal('PUT-by-id updated');
|
|
2788
|
+
Expect(tmpResult.Genre).to.equal('Updated');
|
|
2789
|
+
fDone();
|
|
2790
|
+
}
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
);
|
|
2794
|
+
|
|
2795
|
+
test
|
|
2796
|
+
(
|
|
2797
|
+
'PUT /:IDRecord persists the update and the row is read back at the same ID',
|
|
2798
|
+
function (fDone)
|
|
2799
|
+
{
|
|
2800
|
+
_SuperTest
|
|
2801
|
+
.get(`1.0/Book/${_ByIDRecordID}`)
|
|
2802
|
+
.end(
|
|
2803
|
+
(pError, pResponse) =>
|
|
2804
|
+
{
|
|
2805
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2806
|
+
Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
|
|
2807
|
+
Expect(tmpResult.Title).to.equal('PUT-by-id updated');
|
|
2808
|
+
fDone();
|
|
2809
|
+
}
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
);
|
|
2813
|
+
|
|
2814
|
+
test
|
|
2815
|
+
(
|
|
2816
|
+
'PATCH /:IDRecord behaves the same as PUT — update in place by URL ID',
|
|
2817
|
+
function (fDone)
|
|
2818
|
+
{
|
|
2819
|
+
_SuperTest
|
|
2820
|
+
.patch(`1.0/Book/${_ByIDRecordID}`)
|
|
2821
|
+
.send({ Title: 'PATCH-by-id', Genre: 'Patched' })
|
|
2822
|
+
.end(
|
|
2823
|
+
(pError, pResponse) =>
|
|
2824
|
+
{
|
|
2825
|
+
Expect(pResponse.status).to.equal(200);
|
|
2826
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2827
|
+
Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
|
|
2828
|
+
Expect(tmpResult.Title).to.equal('PATCH-by-id');
|
|
2829
|
+
fDone();
|
|
2830
|
+
}
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
);
|
|
2834
|
+
|
|
2835
|
+
test
|
|
2836
|
+
(
|
|
2837
|
+
'PUT /:IDRecord — URL ID overrides any IDBook in the body',
|
|
2838
|
+
function (fDone)
|
|
2839
|
+
{
|
|
2840
|
+
// Send a body whose IDBook contradicts the URL. The URL
|
|
2841
|
+
// must win so consumers can't accidentally pivot the
|
|
2842
|
+
// update to a different row by stale-body cache.
|
|
2843
|
+
_SuperTest
|
|
2844
|
+
.put(`1.0/Book/${_ByIDRecordID}`)
|
|
2845
|
+
.send({ IDBook: 99999, Title: 'URL wins', Genre: 'Override' })
|
|
2846
|
+
.end(
|
|
2847
|
+
(pError, pResponse) =>
|
|
2848
|
+
{
|
|
2849
|
+
Expect(pResponse.status).to.equal(200);
|
|
2850
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2851
|
+
Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
|
|
2852
|
+
Expect(tmpResult.Title).to.equal('URL wins');
|
|
2853
|
+
fDone();
|
|
2854
|
+
}
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
);
|
|
2858
|
+
|
|
2859
|
+
test
|
|
2860
|
+
(
|
|
2861
|
+
'PUT /Upsert is still routed to the upsert endpoint, not /:IDRecord',
|
|
2862
|
+
function (fDone)
|
|
2863
|
+
{
|
|
2864
|
+
// Regression: the literal /Upsert route must not be
|
|
2865
|
+
// shadowed by the new /:IDRecord parameterized route.
|
|
2866
|
+
_SuperTest
|
|
2867
|
+
.put('1.0/Book/Upsert')
|
|
2868
|
+
.send({ IDBook: _ByIDRecordID, Title: 'Upsert kept routed' })
|
|
2869
|
+
.end(
|
|
2870
|
+
(pError, pResponse) =>
|
|
2871
|
+
{
|
|
2872
|
+
Expect(pResponse.status).to.equal(200);
|
|
2873
|
+
let tmpResult = JSON.parse(pResponse.text);
|
|
2874
|
+
Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
|
|
2875
|
+
Expect(tmpResult.Title).to.equal('Upsert kept routed');
|
|
2876
|
+
fDone();
|
|
2877
|
+
}
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
);
|
|
2881
|
+
|
|
2882
|
+
test
|
|
2883
|
+
(
|
|
2884
|
+
'PUT /:IDRecord — full delete → put-with-same-key → re-read round trip',
|
|
2885
|
+
function (fDone)
|
|
2886
|
+
{
|
|
2887
|
+
// Previously this round-trip required GET → DELETE →
|
|
2888
|
+
// INSERT, and the new INSERT got a fresh auto-id. With
|
|
2889
|
+
// PUT-by-id the primary key is preserved end-to-end.
|
|
2890
|
+
_SuperTest
|
|
2891
|
+
.post('1.0/Book')
|
|
2892
|
+
.send({ Title: 'Round-trip seed' })
|
|
2893
|
+
.end(
|
|
2894
|
+
(pPostErr, pPostRes) =>
|
|
2895
|
+
{
|
|
2896
|
+
let tmpRecord = JSON.parse(pPostRes.text);
|
|
2897
|
+
let tmpID = tmpRecord.IDBook;
|
|
2898
|
+
Expect(tmpID).to.be.above(0);
|
|
2899
|
+
|
|
2900
|
+
_SuperTest
|
|
2901
|
+
.put(`1.0/Book/${tmpID}`)
|
|
2902
|
+
.send({ Title: 'Round-trip updated' })
|
|
2903
|
+
.end(
|
|
2904
|
+
(pPutErr, pPutRes) =>
|
|
2905
|
+
{
|
|
2906
|
+
Expect(pPutRes.status).to.equal(200);
|
|
2907
|
+
|
|
2908
|
+
_SuperTest
|
|
2909
|
+
.get(`1.0/Book/${tmpID}`)
|
|
2910
|
+
.end(
|
|
2911
|
+
(pGetErr, pGetRes) =>
|
|
2912
|
+
{
|
|
2913
|
+
let tmpReadBack = JSON.parse(pGetRes.text);
|
|
2914
|
+
Expect(tmpReadBack.IDBook).to.equal(tmpID);
|
|
2915
|
+
Expect(tmpReadBack.Title).to.equal('Round-trip updated');
|
|
2916
|
+
fDone();
|
|
2917
|
+
}
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2920
|
+
);
|
|
2921
|
+
}
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
);
|
|
2927
|
+
|
|
2928
|
+
// ======================================================================
|
|
2929
|
+
// Soft-deleted collision rename on INSERT
|
|
2930
|
+
// ----------------------------------------------------------------------
|
|
2931
|
+
// New Widget table with a plain UNIQUE INDEX on Code (no
|
|
2932
|
+
// `WHERE Deleted=0` predicate) and a plain composite UNIQUE INDEX on
|
|
2933
|
+
// (Scope, Hash). Verifies that soft-deleted rows whose values would
|
|
2934
|
+
// collide with a new INSERT are renamed deterministically before the
|
|
2935
|
+
// INSERT runs, freeing the slot.
|
|
2936
|
+
// ======================================================================
|
|
2937
|
+
suite
|
|
2938
|
+
(
|
|
2939
|
+
'Soft-deleted collision rename on INSERT',
|
|
2940
|
+
() =>
|
|
2941
|
+
{
|
|
2942
|
+
let _WidgetMeadow = false;
|
|
2943
|
+
let _WidgetEndpoints = false;
|
|
2944
|
+
let _DB = false;
|
|
2945
|
+
|
|
2946
|
+
const _WidgetSchema = [
|
|
2947
|
+
{ Column: 'IDWidget', Type: 'AutoIdentity' },
|
|
2948
|
+
{ Column: 'GUIDWidget', Type: 'AutoGUID' },
|
|
2949
|
+
{ Column: 'CreateDate', Type: 'CreateDate' },
|
|
2950
|
+
{ Column: 'CreatingIDUser', Type: 'CreateIDUser' },
|
|
2951
|
+
{ Column: 'UpdateDate', Type: 'UpdateDate' },
|
|
2952
|
+
{ Column: 'UpdatingIDUser', Type: 'UpdateIDUser' },
|
|
2953
|
+
{ Column: 'Deleted', Type: 'Deleted' },
|
|
2954
|
+
{ Column: 'DeleteDate', Type: 'DeleteDate' },
|
|
2955
|
+
{ Column: 'DeletingIDUser', Type: 'DeleteIDUser' },
|
|
2956
|
+
{ Column: 'Code', Type: 'String', Unique: true },
|
|
2957
|
+
{ Column: 'Scope', Type: 'String', UniqueGroup: 'ScopeHash' },
|
|
2958
|
+
{ Column: 'Hash', Type: 'String', UniqueGroup: 'ScopeHash' },
|
|
2959
|
+
{ Column: 'Name', Type: 'String' }
|
|
2960
|
+
];
|
|
2961
|
+
const _WidgetJsonSchema = {
|
|
2962
|
+
title: 'Widget',
|
|
2963
|
+
type: 'object',
|
|
2964
|
+
properties: {
|
|
2965
|
+
IDWidget: { type: 'integer' },
|
|
2966
|
+
Code: { type: 'string' },
|
|
2967
|
+
Scope: { type: 'string' },
|
|
2968
|
+
Hash: { type: 'string' },
|
|
2969
|
+
Name: { type: 'string' }
|
|
2970
|
+
},
|
|
2971
|
+
required: ['IDWidget']
|
|
2972
|
+
};
|
|
2973
|
+
const _WidgetDefault = {
|
|
2974
|
+
IDWidget: 0,
|
|
2975
|
+
GUIDWidget: '0x0000000000000000',
|
|
2976
|
+
CreateDate: null,
|
|
2977
|
+
CreatingIDUser: 0,
|
|
2978
|
+
UpdateDate: null,
|
|
2979
|
+
UpdatingIDUser: 0,
|
|
2980
|
+
Deleted: 0,
|
|
2981
|
+
DeleteDate: null,
|
|
2982
|
+
DeletingIDUser: 0,
|
|
2983
|
+
Code: '',
|
|
2984
|
+
// Scope/Hash default to null so rows that only exercise the
|
|
2985
|
+
// single-column Unique on Code don't collide with each other
|
|
2986
|
+
// on the composite UNIQUE — SQLite treats each NULL in a
|
|
2987
|
+
// unique index as distinct, so NULL+NULL across many rows
|
|
2988
|
+
// is fine. Tests that exercise the composite supply real
|
|
2989
|
+
// values explicitly.
|
|
2990
|
+
Scope: null,
|
|
2991
|
+
Hash: null,
|
|
2992
|
+
Name: ''
|
|
2993
|
+
};
|
|
2994
|
+
|
|
2995
|
+
const _RenamePrefix = '__mdsd_';
|
|
2996
|
+
|
|
2997
|
+
suiteSetup
|
|
2998
|
+
(
|
|
2999
|
+
function (fDone)
|
|
3000
|
+
{
|
|
3001
|
+
_DB = _Fable.MeadowSQLiteProvider.db;
|
|
3002
|
+
|
|
3003
|
+
// Plain UNIQUE INDEX statements — no `WHERE Deleted=0`
|
|
3004
|
+
// partial-index syntax. Proof that the rename clears the
|
|
3005
|
+
// soft-deleted slot well enough that downstream schemas
|
|
3006
|
+
// don't need dialect-specific gymnastics.
|
|
3007
|
+
_DB.exec(
|
|
3008
|
+
`CREATE TABLE IF NOT EXISTS Widget (
|
|
3009
|
+
IDWidget INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3010
|
+
GUIDWidget TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
|
3011
|
+
CreateDate TEXT,
|
|
3012
|
+
CreatingIDUser INTEGER NOT NULL DEFAULT 0,
|
|
3013
|
+
UpdateDate TEXT,
|
|
3014
|
+
UpdatingIDUser INTEGER NOT NULL DEFAULT 0,
|
|
3015
|
+
Deleted INTEGER NOT NULL DEFAULT 0,
|
|
3016
|
+
DeleteDate TEXT,
|
|
3017
|
+
DeletingIDUser INTEGER NOT NULL DEFAULT 0,
|
|
3018
|
+
Code TEXT NOT NULL DEFAULT '',
|
|
3019
|
+
Scope TEXT,
|
|
3020
|
+
Hash TEXT,
|
|
3021
|
+
Name TEXT NOT NULL DEFAULT ''
|
|
3022
|
+
);
|
|
3023
|
+
CREATE UNIQUE INDEX IF NOT EXISTS widget_code_unique ON Widget(Code);
|
|
3024
|
+
CREATE UNIQUE INDEX IF NOT EXISTS widget_scope_hash_unique ON Widget(Scope, Hash);`
|
|
3025
|
+
);
|
|
3026
|
+
|
|
3027
|
+
_WidgetMeadow = libMeadow.new(_Fable, 'Widget')
|
|
3028
|
+
.setProvider('SQLite')
|
|
3029
|
+
.setSchema(_WidgetSchema)
|
|
3030
|
+
.setJsonSchema(_WidgetJsonSchema)
|
|
3031
|
+
.setDefaultIdentifier('IDWidget')
|
|
3032
|
+
.setDefault(_WidgetDefault);
|
|
3033
|
+
|
|
3034
|
+
_WidgetEndpoints = libMeadowEndpoints.new(_WidgetMeadow);
|
|
3035
|
+
_WidgetEndpoints.connectRoutes(_Orator.serviceServer);
|
|
3036
|
+
|
|
3037
|
+
return fDone();
|
|
3038
|
+
}
|
|
3039
|
+
);
|
|
3040
|
+
|
|
3041
|
+
test
|
|
3042
|
+
(
|
|
3043
|
+
'INSERT with a value colliding with a soft-deleted row succeeds and renames the soft-deleted row',
|
|
3044
|
+
function (fDone)
|
|
3045
|
+
{
|
|
3046
|
+
// Setup: create a widget with Code=alpha and soft-delete it.
|
|
3047
|
+
_SuperTest
|
|
3048
|
+
.post('1.0/Widget')
|
|
3049
|
+
.send({ Code: 'alpha', Name: 'Original' })
|
|
3050
|
+
.end(
|
|
3051
|
+
(pPostErr, pPostRes) =>
|
|
3052
|
+
{
|
|
3053
|
+
Expect(pPostRes.status).to.equal(200);
|
|
3054
|
+
let tmpOriginal = JSON.parse(pPostRes.text);
|
|
3055
|
+
Expect(tmpOriginal.Code).to.equal('alpha');
|
|
3056
|
+
let tmpOriginalID = tmpOriginal.IDWidget;
|
|
3057
|
+
|
|
3058
|
+
_SuperTest
|
|
3059
|
+
.delete(`1.0/Widget/${tmpOriginalID}`)
|
|
3060
|
+
.end(
|
|
3061
|
+
(pDelErr, pDelRes) =>
|
|
3062
|
+
{
|
|
3063
|
+
Expect(pDelRes.status).to.equal(200);
|
|
3064
|
+
// Soft-deleted: row 1 still exists with Code=alpha and Deleted=1.
|
|
3065
|
+
|
|
3066
|
+
// Now insert a NEW widget with Code=alpha. The plain
|
|
3067
|
+
// UNIQUE INDEX would reject this without the rename;
|
|
3068
|
+
// with the rename, the soft-deleted row's Code gets
|
|
3069
|
+
// renamed deterministically and the new INSERT wins
|
|
3070
|
+
// the slot.
|
|
3071
|
+
_SuperTest
|
|
3072
|
+
.post('1.0/Widget')
|
|
3073
|
+
.send({ Code: 'alpha', Name: 'Replacement' })
|
|
3074
|
+
.end(
|
|
3075
|
+
(pNewErr, pNewRes) =>
|
|
3076
|
+
{
|
|
3077
|
+
Expect(pNewRes.status, `unexpected status ${pNewRes.status}; body=${pNewRes.text}`).to.equal(200);
|
|
3078
|
+
let tmpNew = JSON.parse(pNewRes.text);
|
|
3079
|
+
Expect(tmpNew.Code).to.equal('alpha');
|
|
3080
|
+
Expect(tmpNew.Name).to.equal('Replacement');
|
|
3081
|
+
Expect(tmpNew.IDWidget).to.be.above(tmpOriginalID);
|
|
3082
|
+
|
|
3083
|
+
// Verify the soft-deleted row was renamed
|
|
3084
|
+
// deterministically. Inspect via the raw DB
|
|
3085
|
+
// (bypassing the soft-delete filter the API
|
|
3086
|
+
// applies by default).
|
|
3087
|
+
let tmpRow = _DB.prepare('SELECT IDWidget, Code, Deleted FROM Widget WHERE IDWidget = ?').get(tmpOriginalID);
|
|
3088
|
+
Expect(tmpRow).to.exist;
|
|
3089
|
+
Expect(tmpRow.Deleted).to.equal(1);
|
|
3090
|
+
Expect(tmpRow.Code).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
|
|
3091
|
+
|
|
3092
|
+
// Determinism: recompute the expected suffix
|
|
3093
|
+
// from (IDRecord, Column, OriginalValue) and
|
|
3094
|
+
// check it matches.
|
|
3095
|
+
const libCrypto = require('crypto');
|
|
3096
|
+
const tmpExpected = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Code:alpha`).digest('hex').slice(0, 16);
|
|
3097
|
+
Expect(tmpRow.Code).to.equal(tmpExpected);
|
|
3098
|
+
|
|
3099
|
+
fDone();
|
|
3100
|
+
}
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
);
|
|
3104
|
+
}
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
);
|
|
3108
|
+
|
|
3109
|
+
test
|
|
3110
|
+
(
|
|
3111
|
+
'Composite (Scope, Hash) collision: soft-deleted row gets BOTH columns renamed',
|
|
3112
|
+
function (fDone)
|
|
3113
|
+
{
|
|
3114
|
+
_SuperTest
|
|
3115
|
+
.post('1.0/Widget')
|
|
3116
|
+
.send({ Code: 'cmp1', Scope: 'TenantA', Hash: 'hashV1', Name: 'Composite original' })
|
|
3117
|
+
.end(
|
|
3118
|
+
(pPostErr, pPostRes) =>
|
|
3119
|
+
{
|
|
3120
|
+
Expect(pPostRes.status).to.equal(200);
|
|
3121
|
+
let tmpOriginal = JSON.parse(pPostRes.text);
|
|
3122
|
+
let tmpOriginalID = tmpOriginal.IDWidget;
|
|
3123
|
+
|
|
3124
|
+
_SuperTest
|
|
3125
|
+
.delete(`1.0/Widget/${tmpOriginalID}`)
|
|
3126
|
+
.end(
|
|
3127
|
+
(pDelErr, pDelRes) =>
|
|
3128
|
+
{
|
|
3129
|
+
Expect(pDelRes.status).to.equal(200);
|
|
3130
|
+
|
|
3131
|
+
_SuperTest
|
|
3132
|
+
.post('1.0/Widget')
|
|
3133
|
+
.send({ Code: 'cmp2', Scope: 'TenantA', Hash: 'hashV1', Name: 'Composite replacement' })
|
|
3134
|
+
.end(
|
|
3135
|
+
(pNewErr, pNewRes) =>
|
|
3136
|
+
{
|
|
3137
|
+
Expect(pNewRes.status, `unexpected status ${pNewRes.status}; body=${pNewRes.text}`).to.equal(200);
|
|
3138
|
+
let tmpNew = JSON.parse(pNewRes.text);
|
|
3139
|
+
Expect(tmpNew.Scope).to.equal('TenantA');
|
|
3140
|
+
Expect(tmpNew.Hash).to.equal('hashV1');
|
|
3141
|
+
|
|
3142
|
+
// Both Scope and Hash on the soft-deleted row
|
|
3143
|
+
// should now be __mdsd_-prefixed.
|
|
3144
|
+
let tmpRow = _DB.prepare('SELECT IDWidget, Scope, Hash, Deleted FROM Widget WHERE IDWidget = ?').get(tmpOriginalID);
|
|
3145
|
+
Expect(tmpRow.Deleted).to.equal(1);
|
|
3146
|
+
Expect(tmpRow.Scope).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
|
|
3147
|
+
Expect(tmpRow.Hash).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
|
|
3148
|
+
|
|
3149
|
+
const libCrypto = require('crypto');
|
|
3150
|
+
const tmpExpectedScope = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Scope:TenantA`).digest('hex').slice(0, 16);
|
|
3151
|
+
const tmpExpectedHash = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Hash:hashV1`).digest('hex').slice(0, 16);
|
|
3152
|
+
Expect(tmpRow.Scope).to.equal(tmpExpectedScope);
|
|
3153
|
+
Expect(tmpRow.Hash).to.equal(tmpExpectedHash);
|
|
3154
|
+
|
|
3155
|
+
fDone();
|
|
3156
|
+
}
|
|
3157
|
+
);
|
|
3158
|
+
}
|
|
3159
|
+
);
|
|
3160
|
+
}
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
);
|
|
3164
|
+
|
|
3165
|
+
test
|
|
3166
|
+
(
|
|
3167
|
+
'Live (non-deleted) row colliding on a unique index still errors — rename only fires for soft-deleted',
|
|
3168
|
+
function (fDone)
|
|
3169
|
+
{
|
|
3170
|
+
_SuperTest
|
|
3171
|
+
.post('1.0/Widget')
|
|
3172
|
+
.send({ Code: 'beta', Name: 'Live row' })
|
|
3173
|
+
.end(
|
|
3174
|
+
(pPostErr, pPostRes) =>
|
|
3175
|
+
{
|
|
3176
|
+
Expect(pPostRes.status).to.equal(200);
|
|
3177
|
+
|
|
3178
|
+
// No DELETE this time. Insert again with Code=beta —
|
|
3179
|
+
// should error because the live row holds the slot.
|
|
3180
|
+
_SuperTest
|
|
3181
|
+
.post('1.0/Widget')
|
|
3182
|
+
.send({ Code: 'beta', Name: 'Live collision' })
|
|
3183
|
+
.end(
|
|
3184
|
+
(pNewErr, pNewRes) =>
|
|
3185
|
+
{
|
|
3186
|
+
let tmpResult = JSON.parse(pNewRes.text);
|
|
3187
|
+
Expect(tmpResult).to.have.property('Error');
|
|
3188
|
+
fDone();
|
|
3189
|
+
}
|
|
3190
|
+
);
|
|
3191
|
+
}
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
);
|
|
3195
|
+
|
|
3196
|
+
test
|
|
3197
|
+
(
|
|
3198
|
+
'Round-trip: delete → POST-with-same-Code → re-read cleanly, no partial-index needed',
|
|
3199
|
+
function (fDone)
|
|
3200
|
+
{
|
|
3201
|
+
// End-to-end demonstration of the scenario downstream
|
|
3202
|
+
// schemas used to need `WHERE Deleted=0` to support.
|
|
3203
|
+
_SuperTest
|
|
3204
|
+
.post('1.0/Widget')
|
|
3205
|
+
.send({ Code: 'roundtrip', Name: 'gen-1' })
|
|
3206
|
+
.end(
|
|
3207
|
+
(pPostErr, pPostRes) =>
|
|
3208
|
+
{
|
|
3209
|
+
let tmpFirst = JSON.parse(pPostRes.text);
|
|
3210
|
+
Expect(tmpFirst.IDWidget).to.be.above(0);
|
|
3211
|
+
|
|
3212
|
+
_SuperTest
|
|
3213
|
+
.delete(`1.0/Widget/${tmpFirst.IDWidget}`)
|
|
3214
|
+
.end(
|
|
3215
|
+
(pDelErr) =>
|
|
3216
|
+
{
|
|
3217
|
+
_SuperTest
|
|
3218
|
+
.post('1.0/Widget')
|
|
3219
|
+
.send({ Code: 'roundtrip', Name: 'gen-2' })
|
|
3220
|
+
.end(
|
|
3221
|
+
(pPost2Err, pPost2Res) =>
|
|
3222
|
+
{
|
|
3223
|
+
Expect(pPost2Res.status).to.equal(200);
|
|
3224
|
+
let tmpSecond = JSON.parse(pPost2Res.text);
|
|
3225
|
+
Expect(tmpSecond.IDWidget).to.not.equal(tmpFirst.IDWidget);
|
|
3226
|
+
|
|
3227
|
+
_SuperTest
|
|
3228
|
+
.get(`1.0/Widget/${tmpSecond.IDWidget}`)
|
|
3229
|
+
.end(
|
|
3230
|
+
(pGetErr, pGetRes) =>
|
|
3231
|
+
{
|
|
3232
|
+
Expect(pGetRes.status).to.equal(200);
|
|
3233
|
+
let tmpReadBack = JSON.parse(pGetRes.text);
|
|
3234
|
+
Expect(tmpReadBack.Code).to.equal('roundtrip');
|
|
3235
|
+
Expect(tmpReadBack.Name).to.equal('gen-2');
|
|
3236
|
+
fDone();
|
|
3237
|
+
}
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
);
|
|
3247
|
+
|
|
3248
|
+
test
|
|
3249
|
+
(
|
|
3250
|
+
'Upsert (create branch) also benefits from the rename',
|
|
3251
|
+
function (fDone)
|
|
3252
|
+
{
|
|
3253
|
+
// Upsert with a new GUID + a Code that collides with a
|
|
3254
|
+
// soft-deleted row. The rename must run on the create
|
|
3255
|
+
// branch of the upsert too.
|
|
3256
|
+
_SuperTest
|
|
3257
|
+
.post('1.0/Widget')
|
|
3258
|
+
.send({ Code: 'upsertcollide', Name: 'upsert-original' })
|
|
3259
|
+
.end(
|
|
3260
|
+
(pPostErr, pPostRes) =>
|
|
3261
|
+
{
|
|
3262
|
+
let tmpOriginalID = JSON.parse(pPostRes.text).IDWidget;
|
|
3263
|
+
_SuperTest
|
|
3264
|
+
.delete(`1.0/Widget/${tmpOriginalID}`)
|
|
3265
|
+
.end(
|
|
3266
|
+
() =>
|
|
3267
|
+
{
|
|
3268
|
+
// IDWidget=0 forces the upsert path to a Create.
|
|
3269
|
+
_SuperTest
|
|
3270
|
+
.put('1.0/Widget/Upsert')
|
|
3271
|
+
.send({ IDWidget: 0, Code: 'upsertcollide', Name: 'upsert-new' })
|
|
3272
|
+
.end(
|
|
3273
|
+
(pUpErr, pUpRes) =>
|
|
3274
|
+
{
|
|
3275
|
+
Expect(pUpRes.status, `unexpected status ${pUpRes.status}; body=${pUpRes.text}`).to.equal(200);
|
|
3276
|
+
let tmpResult = JSON.parse(pUpRes.text);
|
|
3277
|
+
Expect(tmpResult.Code).to.equal('upsertcollide');
|
|
3278
|
+
Expect(tmpResult.Name).to.equal('upsert-new');
|
|
3279
|
+
Expect(tmpResult.IDWidget).to.not.equal(tmpOriginalID);
|
|
3280
|
+
fDone();
|
|
3281
|
+
}
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
);
|
|
3287
|
+
}
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
);
|
|
2739
3291
|
}
|
|
2740
3292
|
);
|